diff --git a/docs/pm/quick-start.md b/docs/pm/quick-start.md index 879ed77..5d81245 100644 --- a/docs/pm/quick-start.md +++ b/docs/pm/quick-start.md @@ -4,7 +4,7 @@ sidebarLabel: "Quick Start" description: "Install @devintern/pm and create your first AI-drafted ticket in your tracker." section: "PM" order: 1 -dateModified: 2026-08-12 +dateModified: 2026-08-17 tags: ["devintern/pm", "quick start", "jira", "linear", "cli"] --- @@ -12,11 +12,12 @@ tags: ["devintern/pm", "quick start", "jira", "linear", "cli"] **@devintern/pm** automates story and task creation across multiple project management tools with AI. Transform Figma designs, error logs, or requirements into well-structured issues in seconds. -For the primary visual workflow, [download DevIntern PM](https://devintern.com/pm-desktop/). Continue here if you prefer to work in the terminal. +For the primary visual workflow, [download DevIntern PM](https://devintern.com/pm-desktop/). The desktop app checks on launch that **Git** and **at least one supported agent CLI** are on your PATH (including common GUI-launch locations). If something is missing, install it and choose **Check again**. Continue here if you prefer to work in the terminal. ## Prerequisites - **[Node.js](https://nodejs.org) 20 or newer**: Required to run @devintern/pm ([Bun](https://bun.sh) works too) +- **Git**: Required for project folders, GitHub connect, and update-from-remote - AI agent CLI installed and configured (e.g., Claude Code, OpenCode, Codex, Cursor) - Account with at least one supported PM tool (Jira, Linear, Trello, Azure DevOps, Asana, or GitHub) - **For Figma functionality**: [Figma MCP server](https://developers.figma.com/docs/figma-mcp-server/remote-server-installation/) must be installed and configured in your AI agent (Claude Code only) diff --git a/packages/pm-desktop/README.md b/packages/pm-desktop/README.md index 043da11..533932c 100644 --- a/packages/pm-desktop/README.md +++ b/packages/pm-desktop/README.md @@ -11,6 +11,12 @@ bun run build # compile only → out/ bun run test ``` +## Required tools + +The app is not a self-contained agent runtime: **Git** and **at least one supported agent CLI** (Claude Code, OpenCode, Codex, Cursor, …) must already be installed on the machine. On launch the app probes the same PATH it uses to spawn agents, including common GUI-launch locations (`~/.local/bin`, `~/.bun/bin`, Homebrew, …) that a dock/launcher start would otherwise miss. + +If a required tool is missing, a blocking screen names it and how to install it. **Check again** (or switching back to the app after installing) re-runs the probe — there is no “everything is fine” step when the check passes. Optional extras such as sandbox CLIs are not required for core ticket workflows. + ## App icon Installer + dock/taskbar icon: `build/icon.png` (1024×1024 RGBA, with transparent rounded corners diff --git a/packages/pm-desktop/package.json b/packages/pm-desktop/package.json index 6831383..42c7ed8 100644 --- a/packages/pm-desktop/package.json +++ b/packages/pm-desktop/package.json @@ -1,7 +1,7 @@ { "name": "@devintern/pm-desktop", "productName": "DevIntern PM", - "version": "0.9.10", + "version": "0.9.11", "private": true, "description": "Desktop app for @devintern/pm — multi-ticket AI task creation for your tracker.", "author": "DevIntern ", diff --git a/packages/pm-desktop/src/main/ipc.ts b/packages/pm-desktop/src/main/ipc.ts index 7a4ca57..d9c31cc 100644 --- a/packages/pm-desktop/src/main/ipc.ts +++ b/packages/pm-desktop/src/main/ipc.ts @@ -68,6 +68,7 @@ import { } from "./session.ts"; import { listRecentProjectDirs, recordRecentProjectDir } from "./recent-projects.ts"; import { readSettings, updateSettings } from "./settings.ts"; +import { validateRequiredTools } from "./validate-tools.ts"; /** Reveal only known project dirs (bindings, recents, current session) — not arbitrary paths. */ async function isAllowedRevealPath(resolved: string): Promise { @@ -224,6 +225,25 @@ export function registerIpcHandlers(): void { return settings.lastProjectDir ?? null; }); + handle(IPC_CHANNELS.validateRequiredTools, async () => { + const settings = await readSettings(); + let agentEnv: Record = {}; + if (settings.lastProjectDir) { + try { + const { env } = await readProjectEnv(settings.lastProjectDir); + agentEnv = Object.fromEntries( + Object.entries(env).filter( + ([key]) => + key === "AGENT_HARNESS" || key === "AGENT_CLI_PATH" || key.endsWith("_CLI_PATH"), + ), + ); + } catch { + // A stale/unreadable remembered project must not hide process-level tools. + } + } + return validateRequiredTools({ envOverrides: agentEnv }); + }); + handle(IPC_CHANNELS.getRecentProjectDirs, async () => { return listRecentProjectDirs(); }); diff --git a/packages/pm-desktop/src/main/validate-tools.test.ts b/packages/pm-desktop/src/main/validate-tools.test.ts new file mode 100644 index 0000000..fbc36e9 --- /dev/null +++ b/packages/pm-desktop/src/main/validate-tools.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, test } from "bun:test"; +import type { AgentHarness } from "@devintern/agent-harness"; +import { validateRequiredTools } from "./validate-tools.ts"; + +function fakeHarness(name: string, displayName: string, defaultPath: string): AgentHarness { + return { + name, + displayName, + defaultPath, + buildArgs: () => [], + }; +} + +const claude = fakeHarness("claude-code", "Claude Code", "claude"); +const opencode = fakeHarness("opencode", "OpenCode", "opencode"); + +describe("validateRequiredTools", () => { + test("re-applies PATH augmentation before probing", () => { + let augmented = false; + validateRequiredTools({ + augmentPath: () => { + augmented = true; + }, + findGit: () => "/usr/bin/git", + probeGit: () => true, + listInstalled: () => [claude], + listAll: () => [claude, opencode], + }); + expect(augmented).toBe(true); + }); + + test("is ok when git and at least one harness CLI are present", () => { + const result = validateRequiredTools({ + augmentPath: () => {}, + findGit: () => "/usr/bin/git", + probeGit: () => true, + listInstalled: () => [claude, opencode], + listAll: () => [claude, opencode], + }); + expect(result.ok).toBe(true); + expect(result.warnings).toEqual([]); + expect(result.installedHarnesses).toEqual([ + { name: "claude-code", displayName: "Claude Code" }, + { name: "opencode", displayName: "OpenCode" }, + ]); + const git = result.tools.find((t) => t.id === "git"); + const harness = result.tools.find((t) => t.id === "agent-harness"); + expect(git).toMatchObject({ required: true, found: true, detail: "/usr/bin/git" }); + expect(git?.hint).toBeUndefined(); + expect(harness).toMatchObject({ + required: true, + found: true, + detail: "Claude Code, OpenCode", + }); + expect(harness?.hint).toBeUndefined(); + }); + + test("fails with a Git install hint when git is missing", () => { + const result = validateRequiredTools({ + augmentPath: () => {}, + findGit: () => null, + listInstalled: () => [claude], + listAll: () => [claude], + platform: "darwin", + }); + expect(result.ok).toBe(false); + const git = result.tools.find((t) => t.id === "git"); + expect(git?.found).toBe(false); + expect(git?.hint).toContain("xcode-select --install"); + expect(git?.docsUrl).toContain("git-scm.com"); + expect(result.tools.find((t) => t.id === "agent-harness")?.found).toBe(true); + }); + + test("fails when no harness CLI is installed, even if git is present", () => { + const result = validateRequiredTools({ + augmentPath: () => {}, + findGit: () => "/usr/bin/git", + probeGit: () => true, + listInstalled: () => [], + listAll: () => [claude, opencode], + }); + expect(result.ok).toBe(false); + const harness = result.tools.find((t) => t.id === "agent-harness"); + expect(harness?.found).toBe(false); + expect(harness?.hint).toContain("Claude Code (`claude`)"); + expect(harness?.hint).toContain("OpenCode (`opencode`)"); + expect(result.installedHarnesses).toEqual([]); + }); + + test("one installed harness satisfies the agent requirement", () => { + const result = validateRequiredTools({ + augmentPath: () => {}, + findGit: () => "/opt/homebrew/bin/git", + probeGit: () => true, + listInstalled: () => [opencode], + listAll: () => [claude, opencode], + }); + expect(result.ok).toBe(true); + expect(result.tools.every((t) => t.found)).toBe(true); + expect(result.installedHarnesses).toEqual([{ name: "opencode", displayName: "OpenCode" }]); + }); + + test("default probe returns a structured result against the real PATH", () => { + const original = process.env.PATH; + try { + const result = validateRequiredTools(); + expect(result.tools.map((t) => t.id)).toEqual(["git", "agent-harness"]); + expect(typeof result.ok).toBe("boolean"); + expect(Array.isArray(result.warnings)).toBe(true); + } finally { + process.env.PATH = original; + } + }); + + test("fails when Git resolves on PATH but cannot be invoked", () => { + const result = validateRequiredTools({ + augmentPath: () => {}, + findGit: () => "/usr/bin/git", + probeGit: () => false, + listInstalled: () => [claude], + listAll: () => [claude], + platform: "darwin", + }); + + expect(result.ok).toBe(false); + expect(result.tools.find((tool) => tool.id === "git")?.found).toBe(false); + }); + + test.each([ + ["global", { AGENT_HARNESS: "opencode", AGENT_CLI_PATH: "/custom/global-agent" }], + ["harness-specific", { AGENT_HARNESS: "opencode", OPENCODE_CLI_PATH: "/custom/opencode" }], + ])("honors a process-level %s CLI override for the active harness", (_kind, env) => { + const previousHarness = process.env.AGENT_HARNESS; + const previousGlobalPath = process.env.AGENT_CLI_PATH; + const previousHarnessPath = process.env.OPENCODE_CLI_PATH; + delete process.env.AGENT_CLI_PATH; + delete process.env.OPENCODE_CLI_PATH; + Object.assign(process.env, env); + try { + const result = validateRequiredTools({ + augmentPath: () => {}, + findGit: () => "/usr/bin/git", + probeGit: () => true, + listInstalled: ({ currentHarnessName } = {}) => + currentHarnessName === "opencode" && + (process.env.AGENT_CLI_PATH || process.env.OPENCODE_CLI_PATH) + ? [opencode] + : [], + listAll: () => [claude, opencode], + }); + + expect(result.ok).toBe(true); + expect(result.installedHarnesses).toContainEqual({ + name: "opencode", + displayName: "OpenCode", + }); + } finally { + if (previousHarness === undefined) delete process.env.AGENT_HARNESS; + else process.env.AGENT_HARNESS = previousHarness; + if (previousGlobalPath === undefined) delete process.env.AGENT_CLI_PATH; + else process.env.AGENT_CLI_PATH = previousGlobalPath; + if (previousHarnessPath === undefined) delete process.env.OPENCODE_CLI_PATH; + else process.env.OPENCODE_CLI_PATH = previousHarnessPath; + } + }); + + test("honors project-local active harness and CLI path overrides", () => { + const originalHarness = process.env.AGENT_HARNESS; + const originalPath = process.env.AGENT_CLI_PATH; + const result = validateRequiredTools({ + augmentPath: () => {}, + findGit: () => "/usr/bin/git", + probeGit: () => true, + envOverrides: { + AGENT_HARNESS: "opencode", + AGENT_CLI_PATH: "/project/local-agent", + }, + listInstalled: ({ currentHarnessName } = {}) => + currentHarnessName === "opencode" && process.env.AGENT_CLI_PATH === "/project/local-agent" + ? [opencode] + : [], + listAll: () => [claude, opencode], + }); + + expect(result.ok).toBe(true); + expect(result.installedHarnesses).toEqual([{ name: "opencode", displayName: "OpenCode" }]); + expect(process.env.AGENT_HARNESS).toBe(originalHarness); + expect(process.env.AGENT_CLI_PATH).toBe(originalPath); + }); + + test("reports both required tools missing without a stack trace", () => { + const result = validateRequiredTools({ + augmentPath: () => {}, + findGit: () => null, + listInstalled: () => [], + listAll: () => [claude], + platform: "linux", + }); + expect(result.ok).toBe(false); + expect(result.tools.filter((t) => t.required && !t.found)).toHaveLength(2); + for (const tool of result.tools) { + expect(tool.hint).toBeTruthy(); + expect(tool.hint).not.toMatch(/Error:|ENOENT|spawn /); + } + }); +}); diff --git a/packages/pm-desktop/src/main/validate-tools.ts b/packages/pm-desktop/src/main/validate-tools.ts new file mode 100644 index 0000000..e0f7302 --- /dev/null +++ b/packages/pm-desktop/src/main/validate-tools.ts @@ -0,0 +1,132 @@ +/** + * Probe Git and supported agent harness CLIs using the same PATH the app + * uses to spawn agents (including {@link augmentPath} GUI PATH fixes). + */ + +import { spawnSync } from "node:child_process"; +import { + findInPath, + getHarness, + listHarnesses, + listInstalledHarnesses, +} from "@devintern/agent-harness"; +import type { AgentHarness, ListInstalledHarnessesOptions } from "@devintern/agent-harness"; +import { GIT_DOWNLOAD_URL, gitInstallHint, harnessInstallHint } from "../shared/tool-validation.ts"; +import type { ToolCheck, ToolValidation } from "../shared/tool-validation.ts"; +import { augmentPath } from "./path-fix.ts"; + +export interface ValidateToolsDeps { + augmentPath?: () => void; + findGit?: () => string | null; + probeGit?: (path: string) => boolean; + listInstalled?: (options?: ListInstalledHarnessesOptions) => readonly AgentHarness[]; + listAll?: () => readonly AgentHarness[]; + envOverrides?: Readonly>; + platform?: NodeJS.Platform; +} + +function probeGit(path: string): boolean { + const result = spawnSync(path, ["--version"], { + env: process.env, + stdio: "ignore", + timeout: 3_000, + }); + return result.status === 0; +} + +/** Apply project-local agent settings only for the duration of a synchronous probe. */ +function withEnvOverrides(overrides: Readonly>, probe: () => T): T { + const previous = new Map(); + for (const [key, value] of Object.entries(overrides)) { + previous.set(key, process.env[key]); + process.env[key] = value; + } + try { + return probe(); + } finally { + for (const [key, value] of previous) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +function toHintSource(harness: AgentHarness): { + name: string; + displayName: string; + defaultPath: string; +} { + return { + name: harness.name, + displayName: harness.displayName, + defaultPath: harness.defaultPath, + }; +} + +/** + * Re-apply GUI PATH augmentation, then check required tools. + * + * Re-running {@link augmentPath} on each check picks up install dirs that + * appeared after launch (e.g. the user just created `~/.local/bin`). + */ +export function validateRequiredTools(deps: ValidateToolsDeps = {}): ToolValidation { + (deps.augmentPath ?? augmentPath)(); + + const gitCandidate = (deps.findGit ?? (() => findInPath("git")))(); + const gitPath = gitCandidate && (deps.probeGit ?? probeGit)(gitCandidate) ? gitCandidate : null; + const installed = withEnvOverrides(deps.envOverrides ?? {}, () => { + const configuredHarnessName = process.env.AGENT_HARNESS ?? "claude-code"; + const currentHarnessName = getHarness(configuredHarnessName)?.name ?? configuredHarnessName; + return [ + ...(deps.listInstalled ?? ((options) => listInstalledHarnesses(options)))({ + currentHarnessName, + }), + ]; + }); + const registry = [...(deps.listAll ?? (() => listHarnesses()))()]; + const platform = deps.platform ?? process.platform; + + const git: ToolCheck = gitPath + ? { + id: "git", + label: "Git", + required: true, + found: true, + detail: gitPath, + } + : { + id: "git", + label: "Git", + required: true, + found: false, + hint: gitInstallHint(platform), + docsUrl: GIT_DOWNLOAD_URL, + }; + + const harness: ToolCheck = + installed.length > 0 + ? { + id: "agent-harness", + label: "Agent CLI", + required: true, + found: true, + detail: installed.map((h) => h.displayName).join(", "), + } + : { + id: "agent-harness", + label: "Agent CLI", + required: true, + found: false, + hint: harnessInstallHint(registry.map(toHintSource)), + }; + + return { + ok: git.found && harness.found, + tools: [git, harness], + warnings: [], + installedHarnesses: installed.map((h) => ({ + name: h.name, + displayName: h.displayName, + })), + }; +} diff --git a/packages/pm-desktop/src/preload/index.ts b/packages/pm-desktop/src/preload/index.ts index d0d5da4..b7a5b62 100644 --- a/packages/pm-desktop/src/preload/index.ts +++ b/packages/pm-desktop/src/preload/index.ts @@ -41,6 +41,7 @@ const api: PmDesktopApi = { }, getProjectStatus: (dir) => ipcRenderer.invoke(IPC_CHANNELS.getProjectStatus, dir), getLastProjectDir: () => ipcRenderer.invoke(IPC_CHANNELS.getLastProjectDir), + validateRequiredTools: () => ipcRenderer.invoke(IPC_CHANNELS.validateRequiredTools), getRecentProjectDirs: () => ipcRenderer.invoke(IPC_CHANNELS.getRecentProjectDirs), connectGitHubRepo: (input) => ipcRenderer.invoke(IPC_CHANNELS.connectGitHubRepo, input), getGitHubAuthStatus: () => ipcRenderer.invoke(IPC_CHANNELS.getGitHubAuthStatus), diff --git a/packages/pm-desktop/src/renderer/src/App.tsx b/packages/pm-desktop/src/renderer/src/App.tsx index 1b32452..1652428 100644 --- a/packages/pm-desktop/src/renderer/src/App.tsx +++ b/packages/pm-desktop/src/renderer/src/App.tsx @@ -8,6 +8,7 @@ import { ConnectGitHubDialog } from "./components/ConnectGitHubDialog.tsx"; import { ProjectBar } from "./components/ProjectBar.tsx"; import { ProjectSetupWizard } from "./components/ProjectSetupWizard.tsx"; import { ProjectWorkspaceChrome } from "./components/ProjectWorkspaceChrome.tsx"; +import { RequiredToolsGate } from "./components/RequiredToolsGate.tsx"; import { Welcome } from "./components/SetupEmptyState.tsx"; import { TicketSidebar } from "./components/TicketSidebar.tsx"; import { UpdateNotifier } from "./components/UpdateNotifier.tsx"; @@ -37,6 +38,7 @@ import { useCodeDiscoveryDismissed } from "./queries/useCodeDiscoveryDismissed.t import { useIssueTypes } from "./queries/useIssueTypes.ts"; import { useLabels } from "./queries/useLabels.ts"; import { useRecentProjects } from "./queries/useRecentProjects.ts"; +import { useToolValidation } from "./queries/useToolValidation.ts"; import { isBusy } from "./state/app-store.ts"; import { useProjectStore } from "./state/project-store.ts"; import { @@ -52,6 +54,7 @@ import { import { nextTicketId } from "./state/ticket-workspaces.ts"; import type { IpcError, ProjectStatus } from "../../shared/ipc-contract.ts"; import { shouldShowCodeDiscovery } from "../../shared/code-discovery.ts"; +import { isToolValidationBlocking } from "../../shared/tool-validation.ts"; let requestCounter = 0; const nextRequestId = () => `req-${++requestCounter}`; @@ -60,6 +63,19 @@ function toError(error: IpcError | undefined): IpcError { return error ?? { code: "error", message: "Unknown error" }; } +function queryErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if ( + error && + typeof error === "object" && + "message" in error && + typeof error.message === "string" + ) { + return error.message; + } + return "Unknown error"; +} + function defaultComposerForProject(status: ProjectStatus, issueTypes: string[]): ComposerValues { const types = resolveIssueTypes(issueTypes); return { @@ -88,6 +104,11 @@ export function App() { const codeDiscoveryDismissed = codeDiscoveryQuery.data ?? null; const recentProjectsQuery = useRecentProjects(); const recentProjects = recentProjectsQuery.data ?? null; + const toolsQuery = useToolValidation(); + const toolsOk = toolsQuery.data?.ok === true; + const toolsBlocked = isToolValidationBlocking(toolsQuery.data); + const toolsProbeFailed = toolsQuery.isError && !toolsOk; + const toolsError = toolsProbeFailed ? queryErrorMessage(toolsQuery.error) : null; /** Pending close when the ticket still has an agent/operation in flight. */ const [closeConfirmId, setCloseConfirmId] = useState(null); @@ -295,10 +316,16 @@ export function App() { } }, []); - // Restore last project on startup. The recent-projects query auto-fetches - // on mount; mutation paths invalidate it when the eligible list changes. + // Restore last project only after required tools are present, so a missing + // git/agent CLI surfaces on launch instead of as a later spawn error. useEffect(() => { + if (toolsBlocked || toolsProbeFailed) { + useProjectStore.getState().setLoadingProject(false); + return; + } + if (!toolsOk) return; let cancelled = false; + useProjectStore.getState().setLoadingProject(true); void (async () => { try { const last = await window.pm.getLastProjectDir(); @@ -315,7 +342,7 @@ export function App() { return () => { cancelled = true; }; - }, [loadProject]); + }, [loadProject, toolsBlocked, toolsOk, toolsProbeFailed]); // Active-ticket derivations used by the metadata hooks + composer pruning. const activeTicketId = activeTicket?.id; @@ -681,6 +708,32 @@ export function App() { } }; + const aboutDialog = ( + void window.pm.openExternal(url)} + /> + ); + + if (!toolsOk && (toolsQuery.isPending || toolsBlocked || toolsProbeFailed)) { + return ( + <> + { + void toolsQuery.refetch(); + }} + onOpenDocs={(url) => void window.pm.openExternal(url)} + /> + {aboutDialog} + + ); + } + if (!status) { return ( <> @@ -696,6 +749,7 @@ export function App() { onOpenChange={setConnectOpen} onConnected={(next) => void onGitHubConnected(next)} /> + {aboutDialog} ); } @@ -826,12 +880,7 @@ export function App() { - void window.pm.openExternal(url)} - /> + {aboutDialog} ); } diff --git a/packages/pm-desktop/src/renderer/src/components/RequiredToolsGate.test.tsx b/packages/pm-desktop/src/renderer/src/components/RequiredToolsGate.test.tsx new file mode 100644 index 0000000..0968ce0 --- /dev/null +++ b/packages/pm-desktop/src/renderer/src/components/RequiredToolsGate.test.tsx @@ -0,0 +1,121 @@ +import { describe, expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { RequiredToolsGate } from "./RequiredToolsGate.tsx"; +import { qk } from "../queries/keys.ts"; +import { createTestQueryClient, withQueryClient } from "../test-helpers/query-client.tsx"; +import type { ToolValidation } from "../../../shared/tool-validation.ts"; + +const noop = () => {}; + +const missingBoth: ToolValidation = { + ok: false, + warnings: [], + installedHarnesses: [], + tools: [ + { + id: "git", + label: "Git", + required: true, + found: false, + hint: "Install Git and make sure it is on your PATH.", + docsUrl: "https://git-scm.com/downloads", + }, + { + id: "agent-harness", + label: "Agent CLI", + required: true, + found: false, + hint: "Install at least one supported agent CLI (for example Claude Code (`claude`)).", + }, + ], +}; + +function renderGate(props: Partial[0]> = {}) { + const client = createTestQueryClient(); + client.setQueryData(qk.analyticsEnabled, true); + return renderToStaticMarkup( + withQueryClient( + createElement(RequiredToolsGate, { + result: missingBoth, + checking: false, + onRecheck: noop, + ...props, + }), + client, + ), + ); +} + +describe("RequiredToolsGate", () => { + test("shows a pending check without a success step", () => { + const html = renderGate({ result: null }); + expect(html).toContain('data-testid="required-tools-gate"'); + expect(html).toContain('data-state="checking"'); + expect(html).toContain('data-testid="required-tools-checking"'); + expect(html).toContain("Checking that Git and an agent CLI are installed"); + expect(html).not.toContain("everything is fine"); + expect(html).not.toContain('data-testid="required-tools-recheck"'); + }); + + test("names missing tools and how to install them", () => { + const html = renderGate({ onOpenDocs: noop }); + expect(html).toContain('data-state="missing"'); + expect(html).toContain("Required tools are missing"); + expect(html).toContain('data-testid="required-tool-git"'); + expect(html).toContain('data-found="false"'); + expect(html).toContain("not found"); + expect(html).toContain("Install Git and make sure it is on your PATH."); + expect(html).toContain("Claude Code"); + expect(html).toContain("Check again"); + expect(html).toContain("Open install page"); + expect(html).not.toContain("ENOENT"); + expect(html).not.toContain("spawn "); + }); + + test("shows found tools with their resolved detail", () => { + const html = renderGate({ + result: { + ok: false, + warnings: ["Sandbox isolation is optional."], + installedHarnesses: [{ name: "claude-code", displayName: "Claude Code" }], + tools: [ + { + id: "git", + label: "Git", + required: true, + found: true, + detail: "/usr/bin/git", + }, + { + id: "agent-harness", + label: "Agent CLI", + required: true, + found: false, + hint: "Install at least one supported agent CLI.", + }, + ], + }, + }); + expect(html).toContain("/usr/bin/git"); + expect(html).toContain('data-testid="required-tool-git"'); + expect(html).toContain("Sandbox isolation is optional."); + expect(html).toContain("Install at least one supported agent CLI."); + }); + + test("disables Check again while a re-check is in flight", () => { + const html = renderGate({ checking: true }); + expect(html).toContain("Checking…"); + expect(html).toContain("disabled"); + expect(html).not.toContain(">Check again<"); + }); + + test("surfaces a probe error with retry", () => { + const html = renderGate({ result: null, errorMessage: "Main process unavailable" }); + expect(html).toContain('data-state="error"'); + expect(html).toContain('data-testid="required-tools-title"'); + expect(html).toContain("Couldn't check required tools"); + expect(html).toContain("Main process unavailable"); + expect(html).toContain("Check again"); + }); +}); diff --git a/packages/pm-desktop/src/renderer/src/components/RequiredToolsGate.tsx b/packages/pm-desktop/src/renderer/src/components/RequiredToolsGate.tsx new file mode 100644 index 0000000..04a2c15 --- /dev/null +++ b/packages/pm-desktop/src/renderer/src/components/RequiredToolsGate.tsx @@ -0,0 +1,147 @@ +import { CheckCircle2, CircleAlert, Loader2, RefreshCw, XCircle } from "lucide-react"; +import { AnalyticsSettings } from "@/components/AnalyticsSettings"; +import { Button } from "@/components/ui/button"; +import type { ToolCheck, ToolValidation } from "../../../shared/tool-validation.ts"; + +interface RequiredToolsGateProps { + result: ToolValidation | null; + checking: boolean; + /** IPC / unwrap error when the probe itself failed. */ + errorMessage?: string | null; + onRecheck: () => void; + onOpenDocs?: (url: string) => void; +} + +function ToolRow({ tool, onOpenDocs }: { tool: ToolCheck; onOpenDocs?: (url: string) => void }) { + return ( +
  • +
    + {tool.found ? ( + + ) : ( + + )} +
    +

    + {tool.label} + {tool.found && tool.detail ? ( + — {tool.detail} + ) : null} + {!tool.found ? ( + — not found + ) : null} +

    + {!tool.found && tool.hint ? ( +

    {tool.hint}

    + ) : null} + {!tool.found && tool.docsUrl && onOpenDocs ? ( + + ) : null} +
    +
    +
  • + ); +} + +/** + * Blocking launch screen when Git or every supported agent CLI is missing. + * Not shown when all required tools are present. + */ +export function RequiredToolsGate({ + result, + checking, + errorMessage = null, + onRecheck, + onOpenDocs, +}: RequiredToolsGateProps) { + const pending = result === null && !errorMessage; + + return ( +
    +
    + +
    +

    + devintern + / + pm +

    + {pending ? ( + <> +
    + +
    +

    + Checking that Git and an agent CLI are installed… +

    + + ) : ( + <> +
    + +
    +
    +

    + {errorMessage ? "Couldn't check required tools" : "Required tools are missing"} +

    +

    + {errorMessage ?? + "Install the tools below, then check again. The app uses the same PATH it will use to run agents, including common GUI-launch locations."} +

    +
    + {result ? ( +
      + {result.tools + .filter((tool) => tool.required) + .map((tool) => ( + + ))} +
    + ) : null} + {result && result.warnings.length > 0 ? ( +

    + {result.warnings.join(" ")} +

    + ) : null} + + + )} +
    + ); +} diff --git a/packages/pm-desktop/src/renderer/src/queries/keys.ts b/packages/pm-desktop/src/renderer/src/queries/keys.ts index 1804e08..e052369 100644 --- a/packages/pm-desktop/src/renderer/src/queries/keys.ts +++ b/packages/pm-desktop/src/renderer/src/queries/keys.ts @@ -14,6 +14,7 @@ export const qk = { appVersion: ["appVersion"] as const, + toolValidation: ["toolValidation"] as const, codeDiscoveryDismissed: ["codeDiscoveryDismissed"] as const, recentProjects: ["recentProjects"] as const, projectStatus: (dir: string) => ["projectStatus", dir] as const, diff --git a/packages/pm-desktop/src/renderer/src/queries/useToolValidation.test.tsx b/packages/pm-desktop/src/renderer/src/queries/useToolValidation.test.tsx new file mode 100644 index 0000000..f060aa1 --- /dev/null +++ b/packages/pm-desktop/src/renderer/src/queries/useToolValidation.test.tsx @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Window } from "happy-dom"; +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; +import type { PmDesktopApi } from "../../../shared/ipc-contract.ts"; +import type { ToolValidation } from "../../../shared/tool-validation.ts"; +import { useToolValidation } from "./useToolValidation.ts"; + +const validation: ToolValidation = { + ok: true, + tools: [], + warnings: [], + installedHarnesses: [], +}; + +function Probe(): null { + useToolValidation(); + return null; +} + +async function flushMicrotasks(): Promise { + for (let i = 0; i < 3; i++) { + await Bun.sleep(0); + } +} + +describe("useToolValidation", () => { + let domWindow: Window; + let container: HTMLDivElement; + let root: Root; + let client: QueryClient; + let validateRequiredTools: ReturnType; + + beforeEach(() => { + domWindow = new Window(); + globalThis.document = domWindow.document as unknown as Document; + globalThis.window = domWindow as unknown as Window & typeof globalThis.window; + (globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true; + + validateRequiredTools = mock(async () => ({ ok: true as const, value: validation })); + (domWindow as unknown as { pm: PmDesktopApi }).pm = { + validateRequiredTools, + } as unknown as PmDesktopApi; + + container = domWindow.document.createElement("div") as unknown as HTMLDivElement; + domWindow.document.body.appendChild( + container as unknown as Parameters[0], + ); + root = createRoot(container); + client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + refetchOnWindowFocus: false, + }, + }, + }); + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + client.clear(); + await flushMicrotasks(); + }); + container.remove(); + // @ts-expect-error test teardown + delete globalThis.document; + // @ts-expect-error test teardown + delete globalThis.window; + domWindow.close(); + }); + + test("probes IPC again when the app regains focus", async () => { + await act(async () => { + root.render(createElement(QueryClientProvider, { client }, createElement(Probe))); + await flushMicrotasks(); + }); + expect(validateRequiredTools).toHaveBeenCalledTimes(1); + + await act(async () => { + domWindow.dispatchEvent(new domWindow.Event("visibilitychange")); + await flushMicrotasks(); + }); + + expect(validateRequiredTools).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/pm-desktop/src/renderer/src/queries/useToolValidation.ts b/packages/pm-desktop/src/renderer/src/queries/useToolValidation.ts new file mode 100644 index 0000000..87affab --- /dev/null +++ b/packages/pm-desktop/src/renderer/src/queries/useToolValidation.ts @@ -0,0 +1,16 @@ +import { useQuery } from "@tanstack/react-query"; + +import { unwrap } from "../lib/ipc-query.ts"; +import { qk } from "./keys.ts"; + +/** + * Launch-time Git + agent-harness probe. Refetches on window focus so a tool + * installed in another window can clear the gate without a full relaunch. + */ +export function useToolValidation() { + return useQuery({ + queryKey: qk.toolValidation, + queryFn: async () => unwrap(await window.pm.validateRequiredTools()), + refetchOnWindowFocus: "always", + }); +} diff --git a/packages/pm-desktop/src/shared/ipc-contract.ts b/packages/pm-desktop/src/shared/ipc-contract.ts index 8578d6f..47945e1 100644 --- a/packages/pm-desktop/src/shared/ipc-contract.ts +++ b/packages/pm-desktop/src/shared/ipc-contract.ts @@ -20,6 +20,7 @@ import type { PmInitContext, PmTrackerInfo } from "@getdevintern/pm/init"; import type { UpdateStatus } from "./auto-update.ts"; import type { ProjectBindingInfo } from "./project-binding.ts"; import type { ProjectGitSyncStatus } from "./project-git-sync.ts"; +import type { ToolValidation } from "./tool-validation.ts"; export type { LabelListResult, @@ -46,6 +47,19 @@ export { projectGitSyncLabel, shouldShowUpdateFromRemote, } from "./project-git-sync.ts"; +export type { + InstalledHarnessSummary, + RequiredToolId, + ToolCheck, + ToolValidation, +} from "./tool-validation.ts"; +export { + EXAMPLE_HARNESS_IDS, + GIT_DOWNLOAD_URL, + gitInstallHint, + harnessInstallHint, + isToolValidationBlocking, +} from "./tool-validation.ts"; export interface IpcError { code: string; @@ -282,6 +296,11 @@ export interface PmDesktopApi { resolveDroppedFiles(files: File[]): AttachmentRef[]; getProjectStatus(dir: string): Promise>; getLastProjectDir(): Promise>; + /** + * Probe Git + supported agent harness CLIs using the same PATH the app + * uses to spawn agents (including GUI PATH augmentation). + */ + validateRequiredTools(): Promise>; /** * Eligible recent project directories (most recent first). Omits missing paths * and folders that no longer have both `.git` and `.devintern-pm`. @@ -416,6 +435,7 @@ export const IPC_CHANNELS = { saveClipboardImage: "pm:save-clipboard-image", getProjectStatus: "pm:get-project-status", getLastProjectDir: "pm:get-last-project-dir", + validateRequiredTools: "pm:validate-required-tools", getRecentProjectDirs: "pm:get-recent-project-dirs", connectGitHubRepo: "pm:connect-github-repo", getGitHubAuthStatus: "pm:get-github-auth-status", diff --git a/packages/pm-desktop/src/shared/tool-validation.test.ts b/packages/pm-desktop/src/shared/tool-validation.test.ts new file mode 100644 index 0000000..b6dc1d7 --- /dev/null +++ b/packages/pm-desktop/src/shared/tool-validation.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test"; +import { + EXAMPLE_HARNESS_IDS, + GIT_DOWNLOAD_URL, + gitInstallHint, + harnessInstallHint, + isToolValidationBlocking, +} from "./tool-validation.ts"; +import type { HarnessHintSource, ToolValidation } from "./tool-validation.ts"; + +const registry: HarnessHintSource[] = [ + { name: "claude-code", displayName: "Claude Code", defaultPath: "claude" }, + { name: "opencode", displayName: "OpenCode", defaultPath: "opencode" }, + { name: "codex", displayName: "Codex", defaultPath: "codex" }, + { name: "cursor", displayName: "Cursor", defaultPath: "cursor-agent" }, + { name: "grok", displayName: "Grok", defaultPath: "grok" }, +]; + +describe("gitInstallHint", () => { + test("macOS mentions Xcode tools and Homebrew", () => { + const hint = gitInstallHint("darwin"); + expect(hint).toContain("xcode-select --install"); + expect(hint).toContain("brew install git"); + }); + + test("Linux mentions apt and dnf", () => { + const hint = gitInstallHint("linux"); + expect(hint).toContain("sudo apt install git"); + expect(hint).toContain("sudo dnf install git"); + }); + + test("Windows points at the official downloads page", () => { + const hint = gitInstallHint("win32"); + expect(hint).toContain(GIT_DOWNLOAD_URL); + }); +}); + +describe("harnessInstallHint", () => { + test("names well-known CLIs that exist in the registry", () => { + const hint = harnessInstallHint(registry); + expect(hint).toContain("Claude Code (`claude`)"); + expect(hint).toContain("OpenCode (`opencode`)"); + expect(hint).toContain("Codex (`codex`)"); + expect(hint).toContain("Cursor (`cursor-agent`)"); + expect(hint).toContain("AGENT_CLI_PATH"); + expect(hint).toContain("~/.local/bin"); + expect(hint).toContain("and others"); + }); + + test("falls back to the provided list when examples are absent", () => { + const hint = harnessInstallHint([ + { name: "grok", displayName: "Grok", defaultPath: "grok" }, + { name: "pi", displayName: "Pi", defaultPath: "pi" }, + ]); + expect(hint).toContain("Grok (`grok`)"); + expect(hint).toContain("Pi (`pi`)"); + expect(hint).not.toContain("Claude Code"); + }); + + test("example ids stay a short curated set", () => { + expect(EXAMPLE_HARNESS_IDS).toEqual(["claude-code", "opencode", "codex", "cursor"]); + }); +}); + +describe("isToolValidationBlocking", () => { + const ok: ToolValidation = { + ok: true, + tools: [], + warnings: [], + installedHarnesses: [], + }; + + test("blocks only a completed failed check", () => { + expect(isToolValidationBlocking(undefined)).toBe(false); + expect(isToolValidationBlocking(null)).toBe(false); + expect(isToolValidationBlocking(ok)).toBe(false); + expect(isToolValidationBlocking({ ...ok, ok: false })).toBe(true); + }); +}); diff --git a/packages/pm-desktop/src/shared/tool-validation.ts b/packages/pm-desktop/src/shared/tool-validation.ts new file mode 100644 index 0000000..43a776f --- /dev/null +++ b/packages/pm-desktop/src/shared/tool-validation.ts @@ -0,0 +1,87 @@ +/** + * Launch-time required-tool check for PM Desktop. + * + * Required: Git (clone / fetch / update) and at least one supported agent + * harness CLI (story generate / edit / decompose). Optional tools are + * reported as warnings and never block the app. + */ + +export type RequiredToolId = "git" | "agent-harness"; + +/** One required or optional tool probed against the same PATH the app uses to spawn. */ +export interface ToolCheck { + id: RequiredToolId | string; + /** Short label shown in the gate, e.g. "Git" or "Agent CLI". */ + label: string; + required: boolean; + found: boolean; + /** Resolved path or found harness names. */ + detail?: string; + /** Actionable install / PATH hint when missing. */ + hint?: string; + /** Optional download / install page when steps are non-obvious. */ + docsUrl?: string; +} + +export interface InstalledHarnessSummary { + name: string; + displayName: string; +} + +/** Result of {@link validateRequiredTools} — renderer + main share this shape. */ +export interface ToolValidation { + /** True when every required tool is available. */ + ok: boolean; + tools: ToolCheck[]; + /** Non-blocking findings. Empty when there is nothing to warn about. */ + warnings: string[]; + installedHarnesses: InstalledHarnessSummary[]; +} + +/** Public Git downloads page — used when Git is missing. */ +export const GIT_DOWNLOAD_URL = "https://git-scm.com/downloads"; + +/** Well-known harness ids used as install examples (must exist in the registry). */ +export const EXAMPLE_HARNESS_IDS = ["claude-code", "opencode", "codex", "cursor"] as const; + +export function gitInstallHint(platform: NodeJS.Platform = process.platform): string { + if (platform === "darwin") { + return "Install Git and make sure it is on your PATH. On macOS: `xcode-select --install` or `brew install git`."; + } + if (platform === "win32") { + return `Install Git and make sure it is on your PATH. Download it from ${GIT_DOWNLOAD_URL}.`; + } + return "Install Git and make sure it is on your PATH. On Linux: `sudo apt install git` or `sudo dnf install git`."; +} + +export interface HarnessHintSource { + name: string; + displayName: string; + defaultPath: string; +} + +/** + * Compact install guidance naming at least one supported agent CLI. + * Prefers well-known examples that exist in `sources`; falls back to the + * full registry so the copy cannot advertise a removed harness. + */ +export function harnessInstallHint(sources: readonly HarnessHintSource[]): string { + const byName = new Map(sources.map((h) => [h.name, h])); + const examples = EXAMPLE_HARNESS_IDS.map((id) => byName.get(id)).filter( + (h): h is HarnessHintSource => h !== undefined, + ); + const listed = examples.length > 0 ? examples : sources.slice(0, 4); + const exampleText = listed.map((h) => `${h.displayName} (\`${h.defaultPath}\`)`).join(", "); + const more = sources.length > listed.length ? ", and others" : ""; + return ( + `Install at least one supported agent CLI${exampleText ? ` (for example ${exampleText}${more})` : ""} ` + + "and make sure it is on your PATH. " + + "GUI launches also look in ~/.local/bin, ~/.bun/bin, Homebrew, and similar locations. " + + "You can set AGENT_CLI_PATH or _CLI_PATH if the executable lives elsewhere." + ); +} + +/** True when the user must fix their machine before opening a project. */ +export function isToolValidationBlocking(result: ToolValidation | null | undefined): boolean { + return result !== null && result !== undefined && !result.ok; +}