diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 2f2a5ecfa2a..77b9006e6c3 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -250,6 +250,24 @@ function setupAgentIntegrationsFromSettings() { setupAgentIntegrations({ disabledAgentIds: disabledAgentHooks, disabledSkillIds: disabledSkills, + skipUninstalledAgents: true, + }); +} + +const AGENT_REPROVISION_MIN_INTERVAL_MS = 60_000; + +function reprovisionAgentIntegrationsOnFocus() { + let lastRunAt = Date.now(); + app.on("browser-window-focus", () => { + if (!postOnboardingSetup.hasRun()) return; + const now = Date.now(); + if (now - lastRunAt < AGENT_REPROVISION_MIN_INTERVAL_MS) return; + lastRunAt = now; + try { + setupAgentIntegrationsFromSettings(); + } catch (error) { + console.error("[main] Failed to refresh agent integrations:", error); + } }); } @@ -651,6 +669,7 @@ if (!gotTheLock) { setAgentSetupTemplatesDir(path.join(__dirname, "templates")); // macOS permission prompts and writes into other tools' config dirs wait // for a signed-in, onboarded user. + reprovisionAgentIntegrationsOnFocus(); postOnboardingSetup.setSteps([ ["notification-center", registerWithMacOSNotificationCenter], ["apple-events", requestAppleEventsAccess], diff --git a/apps/desktop/src/main/lib/post-onboarding-setup/post-onboarding-setup.ts b/apps/desktop/src/main/lib/post-onboarding-setup/post-onboarding-setup.ts index 1585b9227f7..a6bfdbb8903 100644 --- a/apps/desktop/src/main/lib/post-onboarding-setup/post-onboarding-setup.ts +++ b/apps/desktop/src/main/lib/post-onboarding-setup/post-onboarding-setup.ts @@ -3,6 +3,7 @@ export type PostOnboardingStep = readonly [label: string, run: () => void]; export interface PostOnboardingSetup { setSteps(steps: readonly PostOnboardingStep[]): void; request(): void; + hasRun(): boolean; } /** @@ -39,6 +40,9 @@ export function createPostOnboardingSetup( requested = true; runIfReady(); }, + hasRun() { + return ran; + }, }; } diff --git a/packages/agent-setup/src/agent-setup.ts b/packages/agent-setup/src/agent-setup.ts index 0f4b31565ca..1db94e6e89a 100644 --- a/packages/agent-setup/src/agent-setup.ts +++ b/packages/agent-setup/src/agent-setup.ts @@ -59,6 +59,7 @@ import { } from "./agent-wrappers"; import { createArtifactGuardScript } from "./artifact-guard-hook"; import { resolveDisabledSkillIds } from "./disabled-skills"; +import { isAgentInstalled } from "./installed-agents"; import { createManagedSkills } from "./managed-skills"; import { createNotifyScript } from "./notify-hook"; @@ -213,11 +214,19 @@ interface SetupAgentCapabilitiesOptions { disabledAgentIds?: readonly string[]; /** Skills the user disabled; withheld from provisioning and reaped. */ disabledSkillIds?: readonly string[]; + /** + * Leave agents that are not installed untouched, so no config dir is + * created for a tool the user never installed. + */ + skipUninstalledAgents?: boolean; + isInstalled?: (agentId: AgentSetupTargetId) => boolean; } export function setupAgentCapabilities({ disabledAgentIds = [], disabledSkillIds = [], + skipUninstalledAgents = false, + isInstalled = isAgentInstalled, }: SetupAgentCapabilitiesOptions = {}): void { const disabled = new Set(disabledAgentIds); const failed: string[] = []; @@ -239,9 +248,13 @@ export function setupAgentCapabilities({ for (const target of AGENT_SETUP_TARGETS) { const definition = AGENT_SETUP_DEFINITIONS[target.id]; + const isDisabled = disabled.has(target.id); + if (skipUninstalledAgents && !isDisabled && !isInstalled(target.id)) { + continue; + } runAgentActions( target.id, - disabled.has(target.id) ? definition.teardown : definition.setup, + isDisabled ? definition.teardown : definition.setup, failed, ); } diff --git a/packages/agent-setup/src/index.ts b/packages/agent-setup/src/index.ts index b09606d8709..1051fa77ba0 100644 --- a/packages/agent-setup/src/index.ts +++ b/packages/agent-setup/src/index.ts @@ -40,6 +40,7 @@ export function setupAgentIntegrations( options: { disabledAgentIds?: readonly string[]; disabledSkillIds?: readonly string[]; + skipUninstalledAgents?: boolean; } = {}, ): void { console.log("[agent-setup] Provisioning agent integrations..."); @@ -52,7 +53,11 @@ export function setupAgentIntegrations( fs.mkdirSync(getBashDir(), { recursive: true }); fs.mkdirSync(getOpenCodePluginDir(), { recursive: true }); - setupAgentCapabilities({ disabledAgentIds, disabledSkillIds }); + setupAgentCapabilities({ + disabledAgentIds, + disabledSkillIds, + skipUninstalledAgents: options.skipUninstalledAgents, + }); runSetupAction("zsh-wrapper", createZshWrapper); runSetupAction("bash-wrapper", createBashWrapper); diff --git a/packages/agent-setup/src/installed-agents.test.ts b/packages/agent-setup/src/installed-agents.test.ts new file mode 100644 index 00000000000..d7c6cacf65d --- /dev/null +++ b/packages/agent-setup/src/installed-agents.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { setupAgentCapabilities } from "./agent-setup"; +import { defaultBinarySearchDirs, isAgentInstalled } from "./installed-agents"; + +const ORIGINAL = { + HOME: process.env.HOME, + PATH: process.env.PATH, + SUPERSET_HOME_DIR: process.env.SUPERSET_HOME_DIR, +}; +let home: string; + +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "superset-installed-")); + process.env.HOME = home; + process.env.PATH = path.join(home, "empty-bin"); + process.env.SUPERSET_HOME_DIR = path.join(home, ".superset"); +}); + +afterEach(() => { + for (const [key, value] of Object.entries(ORIGINAL)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + fs.rmSync(home, { recursive: true, force: true }); +}); + +describe("isAgentInstalled", () => { + it("is false on a bare home", () => { + expect(isAgentInstalled("claude", [])).toBe(false); + }); + + it("detects a config dir", () => { + fs.mkdirSync(path.join(home, ".codex")); + expect(isAgentInstalled("codex", [])).toBe(true); + }); + + it("detects a binary in a search dir", () => { + const bin = path.join(home, "bin"); + fs.mkdirSync(bin); + fs.writeFileSync(path.join(bin, "gemini"), "#!/bin/sh\n", { mode: 0o755 }); + expect(isAgentInstalled("gemini", [bin])).toBe(true); + }); + + it("leaves Superset's own wrapper dir out of the search", () => { + const wrappers = path.join(home, ".superset", "bin"); + process.env.PATH = wrappers; + expect(defaultBinarySearchDirs()).not.toContain(wrappers); + }); +}); + +describe("setupAgentCapabilities with skipUninstalledAgents", () => { + it("writes only the installed agent's config", () => { + fs.mkdirSync(path.join(home, ".superset", "hooks"), { recursive: true }); + fs.mkdirSync(path.join(home, ".superset", "bin"), { recursive: true }); + setupAgentCapabilities({ + skipUninstalledAgents: true, + isInstalled: (id) => id === "claude", + }); + + expect(fs.existsSync(path.join(home, ".claude", "settings.json"))).toBe( + true, + ); + for (const dir of [".codex", ".cursor", ".gemini", ".grok", ".factory"]) { + expect(fs.existsSync(path.join(home, dir))).toBe(false); + } + }); +}); diff --git a/packages/agent-setup/src/installed-agents.ts b/packages/agent-setup/src/installed-agents.ts new file mode 100644 index 00000000000..f4ca9ee947f --- /dev/null +++ b/packages/agent-setup/src/installed-agents.ts @@ -0,0 +1,88 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { AgentSetupTargetId } from "./agent-setup-targets"; +import { getBinDir, resolveUserHomeDir } from "./paths"; + +interface AgentFootprint { + binaries: readonly string[]; + homeDirs: readonly string[]; +} + +const AGENT_FOOTPRINTS: Record = { + amp: { binaries: ["amp"], homeDirs: [".config/amp"] }, + claude: { binaries: ["claude"], homeDirs: [".claude"] }, + codex: { binaries: ["codex"], homeDirs: [".codex"] }, + droid: { binaries: ["droid"], homeDirs: [".factory"] }, + opencode: { binaries: ["opencode"], homeDirs: [".config/opencode"] }, + omp: { binaries: ["omp"], homeDirs: [".omp"] }, + pi: { binaries: ["pi"], homeDirs: [".pi"] }, + "cursor-agent": { + binaries: ["cursor-agent"], + homeDirs: [".cursor"], + }, + gemini: { binaries: ["gemini"], homeDirs: [".gemini"] }, + mastracode: { binaries: ["mastracode"], homeDirs: [".mastracode"] }, + kimi: { binaries: ["kimi"], homeDirs: [".kimi-code"] }, + grok: { binaries: ["grok"], homeDirs: [".grok"] }, + copilot: { binaries: ["copilot"], homeDirs: [".copilot"] }, + vibe: { binaries: ["vibe"], homeDirs: [".vibe"] }, + devin: { binaries: ["devin"], homeDirs: [".config/devin"] }, + muse: { binaries: ["muse"], homeDirs: [".config/muse"] }, + ufo: { binaries: ["ufo"], homeDirs: [] }, + agy: { binaries: ["agy"], homeDirs: [] }, +}; + +const EXTRA_BINARY_DIRS = [ + ".local/bin", + ".bun/bin", + ".cargo/bin", + ".npm-global/bin", + ".claude/local", + ".volta/bin", +]; + +const SYSTEM_BINARY_DIRS = ["/opt/homebrew/bin", "/usr/local/bin"]; + +export function defaultBinarySearchDirs(): string[] { + const home = resolveUserHomeDir(); + const wrapperDir = path.resolve(getBinDir()); + const fromPath = (process.env.PATH ?? "").split(path.delimiter); + return [ + ...fromPath, + ...EXTRA_BINARY_DIRS.map((dir) => path.join(home, dir)), + ...SYSTEM_BINARY_DIRS, + ].filter((dir) => dir && path.resolve(dir) !== wrapperDir); +} + +function hasBinary(names: readonly string[], dirs: readonly string[]): boolean { + const extensions = process.platform === "win32" ? ["", ".exe", ".cmd"] : [""]; + return names.some((name) => + dirs.some((dir) => + extensions.some((extension) => { + try { + fs.accessSync(path.join(dir, name + extension), fs.constants.X_OK); + return true; + } catch { + return false; + } + }), + ), + ); +} + +/** + * An agent counts as installed when its binary is on PATH (or a usual install + * dir — Finder-launched apps get a minimal PATH) or its home config dir + * exists. Superset's own wrapper dir is ignored. + */ +export function isAgentInstalled( + agentId: AgentSetupTargetId, + binaryDirs: readonly string[] = defaultBinarySearchDirs(), +): boolean { + const footprint = AGENT_FOOTPRINTS[agentId]; + const home = resolveUserHomeDir(); + if (footprint.homeDirs.some((dir) => fs.existsSync(path.join(home, dir)))) { + return true; + } + return hasBinary(footprint.binaries, binaryDirs); +}