diff --git a/.gitignore b/.gitignore index a24d7bb44ce..c1e234a273d 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,4 @@ superset-dev-data/ !.codex/config.toml !.codex/commands !.codex/prompts +.amp/* diff --git a/README.md b/README.md index 2d41607c3f5..86ef7998d8a 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ Superset works with any CLI-based coding agent, including: | Agent | Status | |:------|:-------| +| [Amp Code](https://ampcode.com/) | Fully supported | | [Claude Code](https://github.com/anthropics/claude-code) | Fully supported | | [OpenAI Codex CLI](https://github.com/openai/codex) | Fully supported | | [Cursor Agent](https://docs.cursor.com/agent) | Fully supported | diff --git a/apps/desktop/docs/EXTERNAL_FILES.md b/apps/desktop/docs/EXTERNAL_FILES.md index 22980e1e6c3..aa14854b286 100644 --- a/apps/desktop/docs/EXTERNAL_FILES.md +++ b/apps/desktop/docs/EXTERNAL_FILES.md @@ -17,6 +17,7 @@ This separation prevents multiple instances from interfering with each other. | File | Purpose | |------|---------| +| `amp` | Wrapper for Amp CLI that preserves Superset terminal context | | `claude` | Wrapper for Claude Code CLI that injects notification hooks | | `codex` | Wrapper for Codex CLI that injects notification hooks | | `droid` | Wrapper for Factory Droid CLI that preserves Superset hook integration | diff --git a/apps/desktop/src/lib/trpc/routers/settings/agent-preset-router.utils.test.ts b/apps/desktop/src/lib/trpc/routers/settings/agent-preset-router.utils.test.ts index abcbf78285b..5a107afb4b3 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/agent-preset-router.utils.test.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/agent-preset-router.utils.test.ts @@ -2,8 +2,12 @@ import { describe, expect, test } from "bun:test"; import { getBuiltinAgentDefinition } from "@superset/shared/agent-catalog"; import { TRPCError } from "@trpc/server"; import { + createCustomAgentInputSchema, normalizeAgentPresetPatch, + normalizeCreateCustomAgentInput, + normalizeCustomAgentPatch, updateAgentPresetInputSchema, + updateCustomAgentInputSchema, } from "./agent-preset-router.utils"; describe("updateAgentPresetInputSchema", () => { @@ -76,3 +80,76 @@ describe("normalizeAgentPresetPatch", () => { ).toThrow(TRPCError); }); }); + +describe("custom agent schemas", () => { + test("rejects empty custom-agent patches", () => { + const result = updateCustomAgentInputSchema.safeParse({ + id: "custom:test", + patch: {}, + }); + + expect(result.success).toBe(false); + }); + + test("accepts custom-agent create payloads", () => { + const result = createCustomAgentInputSchema.safeParse({ + label: " Team Agent ", + command: " team-agent ", + taskPromptTemplate: " Task {{slug}} ", + }); + + expect(result.success).toBe(true); + }); +}); + +describe("custom agent normalization", () => { + test("trims custom-agent create input and clears blank optional strings", () => { + const normalized = normalizeCreateCustomAgentInput({ + label: " Team Agent ", + description: " ", + command: " team-agent ", + promptCommand: " team-agent ", + promptCommandSuffix: " ", + promptTransport: "argv", + taskPromptTemplate: " Task {{slug}} ", + enabled: false, + }); + + expect(normalized).toEqual({ + label: "Team Agent", + description: undefined, + command: "team-agent", + promptCommand: undefined, + promptCommandSuffix: undefined, + promptTransport: undefined, + taskPromptTemplate: "Task {{slug}}", + enabled: false, + }); + }); + + test("normalizes custom-agent patches and clears blank optional strings to null", () => { + const normalized = normalizeCustomAgentPatch({ + promptCommand: " ", + description: " ", + promptCommandSuffix: " ", + promptTransport: "argv", + command: " team-agent ", + }); + + expect(normalized).toEqual({ + promptCommand: null, + description: null, + promptCommandSuffix: null, + promptTransport: null, + command: "team-agent", + }); + }); + + test("rejects custom-agent task templates with unknown variables", () => { + expect(() => + normalizeCustomAgentPatch({ + taskPromptTemplate: "Task {{unknown}}", + }), + ).toThrow(TRPCError); + }); +}); diff --git a/apps/desktop/src/lib/trpc/routers/settings/agent-preset-router.utils.ts b/apps/desktop/src/lib/trpc/routers/settings/agent-preset-router.utils.ts index 6b6e6b68b5d..2693ad7e22a 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/agent-preset-router.utils.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/agent-preset-router.utils.ts @@ -1,6 +1,10 @@ +import { PROMPT_TRANSPORTS } from "@superset/local-db"; import type { AgentDefinition } from "@superset/shared/agent-catalog"; import { TRPCError } from "@trpc/server"; -import type { AgentPresetPatch } from "shared/utils/agent-settings"; +import type { + AgentPresetPatch, + CustomAgentDefinitionPatch, +} from "shared/utils/agent-settings"; import { validateTaskPromptTemplate } from "shared/utils/agent-settings"; import { z } from "zod"; @@ -22,6 +26,35 @@ export const updateAgentPresetInputSchema = z.object({ }), }); +export const createCustomAgentInputSchema = z.object({ + label: z.string(), + description: z.string().nullable().optional(), + command: z.string(), + promptCommand: z.string().optional(), + promptCommandSuffix: z.string().nullable().optional(), + promptTransport: z.enum(PROMPT_TRANSPORTS).optional(), + taskPromptTemplate: z.string(), + enabled: z.boolean().optional(), +}); + +export const updateCustomAgentInputSchema = z.object({ + id: z.string().regex(/^custom:/), + patch: z + .object({ + label: z.string().optional(), + description: z.string().nullable().optional(), + command: z.string().optional(), + promptCommand: z.string().nullable().optional(), + promptCommandSuffix: z.string().nullable().optional(), + promptTransport: z.enum(PROMPT_TRANSPORTS).nullable().optional(), + taskPromptTemplate: z.string().optional(), + enabled: z.boolean().optional(), + }) + .refine((patch) => Object.keys(patch).length > 0, { + message: "Patch must include at least one field", + }), +}); + function toTrimmedRequiredValue(field: string, value: string): string { const trimmed = value.trim(); if (!trimmed) { @@ -95,3 +128,100 @@ export function normalizeAgentPresetPatch({ return normalized; } + +function normalizeOptionalText( + value: string | null | undefined, +): string | null { + const normalized = value?.trim() ?? ""; + return normalized ? normalized : null; +} + +export function normalizeCreateCustomAgentInput( + input: z.infer, +) { + const command = toTrimmedRequiredValue("Command", input.command); + const taskPromptTemplate = toTrimmedRequiredValue( + "Task prompt template", + input.taskPromptTemplate, + ); + const validation = validateTaskPromptTemplate(taskPromptTemplate); + if (!validation.valid) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Unknown task prompt variables: ${validation.unknownVariables.join(", ")}`, + }); + } + + const promptCommand = normalizeOptionalText(input.promptCommand) ?? undefined; + + return { + label: toTrimmedRequiredValue("Label", input.label), + description: normalizeOptionalText(input.description) ?? undefined, + command, + promptCommand: promptCommand === command ? undefined : promptCommand, + promptCommandSuffix: + normalizeOptionalText(input.promptCommandSuffix) ?? undefined, + promptTransport: + input.promptTransport && input.promptTransport !== "argv" + ? input.promptTransport + : undefined, + taskPromptTemplate, + enabled: input.enabled, + } as const; +} + +export function normalizeCustomAgentPatch( + patch: z.infer["patch"], +): CustomAgentDefinitionPatch { + const normalized: CustomAgentDefinitionPatch = {}; + + if (patch.enabled !== undefined) { + normalized.enabled = patch.enabled; + } + if (patch.label !== undefined) { + normalized.label = toTrimmedRequiredValue("Label", patch.label); + } + if (patch.description !== undefined) { + normalized.description = normalizeOptionalText(patch.description); + } + if (patch.command !== undefined) { + normalized.command = toTrimmedRequiredValue("Command", patch.command); + } + if (patch.promptCommand !== undefined) { + normalized.promptCommand = normalizeOptionalText(patch.promptCommand); + } + if (patch.promptCommandSuffix !== undefined) { + normalized.promptCommandSuffix = normalizeOptionalText( + patch.promptCommandSuffix, + ); + } + if (patch.promptTransport !== undefined) { + normalized.promptTransport = + patch.promptTransport && patch.promptTransport !== "argv" + ? patch.promptTransport + : null; + } + if (patch.taskPromptTemplate !== undefined) { + const taskPromptTemplate = toTrimmedRequiredValue( + "Task prompt template", + patch.taskPromptTemplate, + ); + const validation = validateTaskPromptTemplate(taskPromptTemplate); + if (!validation.valid) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Unknown task prompt variables: ${validation.unknownVariables.join(", ")}`, + }); + } + normalized.taskPromptTemplate = taskPromptTemplate; + } + + if (Object.keys(normalized).length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Patch must include at least one supported field", + }); + } + + return normalized; +} diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index c59032c3772..7e86917e93b 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -13,6 +13,7 @@ import { import { AGENT_PRESET_COMMANDS, AGENT_PRESET_DESCRIPTIONS, + DEFAULT_TERMINAL_PRESET_AGENT_TYPES, } from "@superset/shared/agent-command"; import { TRPCError } from "@trpc/server"; import { app } from "electron"; @@ -37,18 +38,27 @@ import { } from "shared/ringtones"; import { type AgentDefinitionId, + applyCustomAgentDefinitionPatch, createOverrideEnvelopeWithPatch, + deleteCustomAgentDefinition, getAgentDefinitionById, + getCustomAgentDefinitionById, readAgentPresetOverrides, resetAgentPresetOverride, + resetAllAgentPresetOverrides, resolveAgentConfigs, + upsertCustomAgentDefinition, } from "shared/utils/agent-settings"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { getGitAuthorName, getGitHubUsername } from "../workspaces/utils/git"; import { + createCustomAgentInputSchema, normalizeAgentPresetPatch, + normalizeCreateCustomAgentInput, + normalizeCustomAgentPatch, updateAgentPresetInputSchema, + updateCustomAgentInputSchema, } from "./agent-preset-router.utils"; import { setFontSettingsSchema, @@ -136,6 +146,29 @@ function saveAgentPresetOverrides(overrides: AgentPresetOverrideEnvelope) { .run(); } +function saveAgentCustomDefinitions(definitions: AgentCustomDefinition[]) { + localDb + .insert(settings) + .values({ + id: 1, + agentCustomDefinitions: definitions, + }) + .onConflictDoUpdate({ + target: settings.id, + set: { agentCustomDefinitions: definitions }, + }) + .run(); +} + +function clearCustomAgentPresetOverride(id: `custom:${string}`) { + saveAgentPresetOverrides( + resetAgentPresetOverride({ + currentOverrides: readRawAgentPresetOverrides(), + id, + }), + ); +} + function getResolvedAgentPresets() { return resolveAgentConfigs({ customDefinitions: readRawAgentCustomDefinitions(), @@ -143,24 +176,13 @@ function getResolvedAgentPresets() { }); } -const DEFAULT_PRESET_AGENTS = [ - "claude", - "codex", - "copilot", - "mastracode", - "opencode", - "pi", - "gemini", -] as const; - -const DEFAULT_PRESETS: Omit[] = DEFAULT_PRESET_AGENTS.map( - (name) => ({ +const DEFAULT_PRESETS: Omit[] = + DEFAULT_TERMINAL_PRESET_AGENT_TYPES.map((name) => ({ name, description: AGENT_PRESET_DESCRIPTIONS[name], cwd: "", commands: AGENT_PRESET_COMMANDS[name], - }), -); + })); function initializeDefaultPresets() { const row = getSettings(); @@ -204,6 +226,84 @@ export const createSettingsRouter = () => { return getNormalizedTerminalPresets(); }), getAgentPresets: publicProcedure.query(() => getResolvedAgentPresets()), + createCustomAgent: publicProcedure + .input(createCustomAgentInputSchema) + .mutation(({ input }) => { + const definition = { + id: `custom:${crypto.randomUUID()}` as const, + kind: "terminal" as const, + ...normalizeCreateCustomAgentInput(input), + }; + const nextDefinitions = upsertCustomAgentDefinition({ + currentDefinitions: readRawAgentCustomDefinitions(), + definition, + }); + + saveAgentCustomDefinitions(nextDefinitions); + clearCustomAgentPresetOverride(definition.id); + + return getResolvedAgentPresets().find( + (preset) => preset.id === definition.id, + ); + }), + updateCustomAgent: publicProcedure + .input(updateCustomAgentInputSchema) + .mutation(({ input }) => { + const definition = getCustomAgentDefinitionById({ + customDefinitions: readRawAgentCustomDefinitions(), + id: input.id as `custom:${string}`, + }); + if (!definition) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Custom agent ${input.id} not found`, + }); + } + + const nextDefinitions = upsertCustomAgentDefinition({ + currentDefinitions: readRawAgentCustomDefinitions(), + definition: applyCustomAgentDefinitionPatch({ + definition, + patch: normalizeCustomAgentPatch(input.patch), + }), + }); + + saveAgentCustomDefinitions(nextDefinitions); + clearCustomAgentPresetOverride(input.id as `custom:${string}`); + + return getResolvedAgentPresets().find( + (preset) => preset.id === input.id, + ); + }), + deleteCustomAgent: publicProcedure + .input(z.object({ id: z.string().regex(/^custom:/) })) + .mutation(({ input }) => { + const existingDefinition = getCustomAgentDefinitionById({ + customDefinitions: readRawAgentCustomDefinitions(), + id: input.id as `custom:${string}`, + }); + if (!existingDefinition) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Custom agent ${input.id} not found`, + }); + } + + saveAgentCustomDefinitions( + deleteCustomAgentDefinition({ + currentDefinitions: readRawAgentCustomDefinitions(), + id: input.id as `custom:${string}`, + }), + ); + saveAgentPresetOverrides( + resetAgentPresetOverride({ + currentOverrides: readRawAgentPresetOverrides(), + id: input.id as AgentDefinitionId, + }), + ); + + return { success: true }; + }), updateAgentPreset: publicProcedure .input(updateAgentPresetInputSchema) .mutation(({ input }) => { @@ -217,6 +317,12 @@ export const createSettingsRouter = () => { message: `Agent preset ${input.id} not found`, }); } + if (definition.source === "user") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Custom agent ${input.id} must be edited through custom-agent settings`, + }); + } const normalizedPatch = normalizeAgentPresetPatch({ definition, @@ -246,7 +352,7 @@ export const createSettingsRouter = () => { return { success: true }; }), resetAllAgentPresets: publicProcedure.mutation(() => { - saveAgentPresetOverrides({ version: 1, presets: [] }); + saveAgentPresetOverrides(resetAllAgentPresetOverrides()); return { success: true }; }), createTerminalPreset: publicProcedure diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-amp.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-amp.ts new file mode 100644 index 00000000000..0ab3df0902f --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-amp.ts @@ -0,0 +1,11 @@ +import { buildWrapperScript, createWrapper } from "./agent-wrappers-common"; + +/** + * Creates the Amp wrapper that preserves Superset's terminal environment. + * Amp does not currently expose stable hook support, so this wrapper is a + * pass-through binary shim only. + */ +export function createAmpWrapper(): void { + const script = buildWrapperScript("amp", `exec "$REAL_BIN" "$@"`); + createWrapper("amp", script); +} diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-common.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-common.ts index 26e5720a8fc..55a2f14ab8a 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-common.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-common.ts @@ -1,17 +1,10 @@ import fs from "node:fs"; import path from "node:path"; +import { SUPERSET_MANAGED_BINARIES } from "./desktop-agent-capabilities"; import { BIN_DIR } from "./paths"; export const WRAPPER_MARKER = "# Superset agent-wrapper v1"; -export const SUPERSET_MANAGED_BINARIES = [ - "claude", - "codex", - "droid", - "opencode", - "gemini", - "copilot", - "mastracode", -] as const; +export { SUPERSET_MANAGED_BINARIES }; const SUPERSET_MANAGED_HOOK_PATH_PATTERN = /\/\.superset(?:-[^/'"\s\\]+)?\//; diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts index 975f3d7c24b..a12bd4fe870 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts @@ -56,6 +56,7 @@ mock.module("node:os", () => ({ })); const { + createAmpWrapper, buildCodexWrapperExecLine, buildCopilotWrapperExecLine, buildWrapperScript, @@ -260,6 +261,17 @@ exit 0 expect(wrapper).toContain('exec "$REAL_BIN" "$@"'); }); + it("creates amp wrapper passthrough", () => { + createAmpWrapper(); + + const wrapperPath = path.join(TEST_BIN_DIR, "amp"); + const wrapper = readFileSync(wrapperPath, "utf-8"); + + expect(wrapper).toContain("# Superset wrapper for amp"); + expect(wrapper).toContain('REAL_BIN="$(find_real_binary "amp")"'); + expect(wrapper).toContain('exec "$REAL_BIN" "$@"'); + }); + it("creates droid wrapper passthrough", () => { createDroidWrapper(); diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts index aa66274d808..4162078113c 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts @@ -1,3 +1,4 @@ +export { createAmpWrapper } from "./agent-wrappers-amp"; export { buildCodexWrapperExecLine, cleanupGlobalOpenCodePlugin, diff --git a/apps/desktop/src/main/lib/agent-setup/desktop-agent-capabilities.ts b/apps/desktop/src/main/lib/agent-setup/desktop-agent-capabilities.ts new file mode 100644 index 00000000000..2f0c60a93de --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/desktop-agent-capabilities.ts @@ -0,0 +1,100 @@ +import type { AgentType } from "@superset/shared/agent-command"; + +export type SupersetManagedBinary = AgentType | "droid"; + +export const DESKTOP_AGENT_SETUP_ACTIONS = [ + "notify-script", + "cleanup-global-opencode-plugin", + "amp-wrapper", + "claude-settings-json", + "claude-wrapper", + "codex-hooks-json", + "codex-wrapper", + "droid-wrapper", + "droid-settings-json", + "opencode-plugin", + "opencode-wrapper", + "cursor-hook-script", + "cursor-agent-wrapper", + "cursor-hooks-json", + "gemini-hook-script", + "gemini-wrapper", + "gemini-settings-json", + "mastra-wrapper", + "mastra-hooks-json", + "copilot-hook-script", + "copilot-wrapper", +] as const; + +export type DesktopAgentSetupAction = + (typeof DESKTOP_AGENT_SETUP_ACTIONS)[number]; + +interface DesktopAgentSetupTarget { + id: AgentType | "droid"; + setupActions: readonly DesktopAgentSetupAction[]; + managedBinary?: boolean; +} + +export const DESKTOP_AGENT_SETUP_BOOTSTRAP_ACTIONS = [ + "cleanup-global-opencode-plugin", + "notify-script", +] as const satisfies readonly DesktopAgentSetupAction[]; + +export const DESKTOP_AGENT_SETUP_TARGETS = [ + { + id: "amp", + setupActions: ["amp-wrapper"], + managedBinary: true, + }, + { + id: "claude", + setupActions: ["claude-settings-json", "claude-wrapper"], + managedBinary: true, + }, + { + id: "codex", + setupActions: ["codex-hooks-json", "codex-wrapper"], + managedBinary: true, + }, + { + id: "droid", + setupActions: ["droid-wrapper", "droid-settings-json"], + managedBinary: true, + }, + { + id: "opencode", + setupActions: ["opencode-plugin", "opencode-wrapper"], + managedBinary: true, + }, + { + id: "cursor-agent", + setupActions: [ + "cursor-hook-script", + "cursor-agent-wrapper", + "cursor-hooks-json", + ], + }, + { + id: "gemini", + setupActions: [ + "gemini-hook-script", + "gemini-wrapper", + "gemini-settings-json", + ], + managedBinary: true, + }, + { + id: "mastracode", + setupActions: ["mastra-wrapper", "mastra-hooks-json"], + managedBinary: true, + }, + { + id: "copilot", + setupActions: ["copilot-hook-script", "copilot-wrapper"], + managedBinary: true, + }, +] as const satisfies readonly DesktopAgentSetupTarget[]; + +export const SUPERSET_MANAGED_BINARIES = DESKTOP_AGENT_SETUP_TARGETS.filter( + (target) => "managedBinary" in target && target.managedBinary, +).map((target) => target.id) satisfies SupersetManagedBinary[]; diff --git a/apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts b/apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts new file mode 100644 index 00000000000..e25e4baa66e --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts @@ -0,0 +1,65 @@ +import { + cleanupGlobalOpenCodePlugin, + createAmpWrapper, + createClaudeSettingsJson, + createClaudeWrapper, + createCodexHooksJson, + createCodexWrapper, + createCopilotHookScript, + createCopilotWrapper, + createCursorAgentWrapper, + createCursorHookScript, + createCursorHooksJson, + createDroidSettingsJson, + createDroidWrapper, + createGeminiHookScript, + createGeminiSettingsJson, + createGeminiWrapper, + createMastraHooksJson, + createMastraWrapper, + createOpenCodePlugin, + createOpenCodeWrapper, +} from "./agent-wrappers"; +import { + DESKTOP_AGENT_SETUP_BOOTSTRAP_ACTIONS, + DESKTOP_AGENT_SETUP_TARGETS, + type DesktopAgentSetupAction, +} from "./desktop-agent-capabilities"; +import { createNotifyScript } from "./notify-hook"; + +const DESKTOP_AGENT_SETUP_RUNNERS: Record void> = + { + "notify-script": createNotifyScript, + "cleanup-global-opencode-plugin": cleanupGlobalOpenCodePlugin, + "amp-wrapper": createAmpWrapper, + "claude-settings-json": createClaudeSettingsJson, + "claude-wrapper": createClaudeWrapper, + "codex-hooks-json": createCodexHooksJson, + "codex-wrapper": createCodexWrapper, + "droid-wrapper": createDroidWrapper, + "droid-settings-json": createDroidSettingsJson, + "opencode-plugin": createOpenCodePlugin, + "opencode-wrapper": createOpenCodeWrapper, + "cursor-hook-script": createCursorHookScript, + "cursor-agent-wrapper": createCursorAgentWrapper, + "cursor-hooks-json": createCursorHooksJson, + "gemini-hook-script": createGeminiHookScript, + "gemini-wrapper": createGeminiWrapper, + "gemini-settings-json": createGeminiSettingsJson, + "mastra-wrapper": createMastraWrapper, + "mastra-hooks-json": createMastraHooksJson, + "copilot-hook-script": createCopilotHookScript, + "copilot-wrapper": createCopilotWrapper, + }; + +export function setupDesktopAgentCapabilities(): void { + for (const action of DESKTOP_AGENT_SETUP_BOOTSTRAP_ACTIONS) { + DESKTOP_AGENT_SETUP_RUNNERS[action](); + } + + for (const target of DESKTOP_AGENT_SETUP_TARGETS) { + for (const action of target.setupActions) { + DESKTOP_AGENT_SETUP_RUNNERS[action](); + } + } +} diff --git a/apps/desktop/src/main/lib/agent-setup/index.ts b/apps/desktop/src/main/lib/agent-setup/index.ts index 8476c7bfcd3..3b8ddd33324 100644 --- a/apps/desktop/src/main/lib/agent-setup/index.ts +++ b/apps/desktop/src/main/lib/agent-setup/index.ts @@ -1,26 +1,5 @@ import fs from "node:fs"; -import { - cleanupGlobalOpenCodePlugin, - createClaudeSettingsJson, - createClaudeWrapper, - createCodexHooksJson, - createCodexWrapper, - createCopilotHookScript, - createCopilotWrapper, - createCursorAgentWrapper, - createCursorHookScript, - createCursorHooksJson, - createDroidSettingsJson, - createDroidWrapper, - createGeminiHookScript, - createGeminiSettingsJson, - createGeminiWrapper, - createMastraHooksJson, - createMastraWrapper, - createOpenCodePlugin, - createOpenCodeWrapper, -} from "./agent-wrappers"; -import { createNotifyScript } from "./notify-hook"; +import { setupDesktopAgentCapabilities } from "./desktop-agent-setup"; import { BASH_DIR, BIN_DIR, @@ -45,27 +24,7 @@ export function setupAgentHooks(): void { fs.mkdirSync(BASH_DIR, { recursive: true }); fs.mkdirSync(OPENCODE_PLUGIN_DIR, { recursive: true }); - cleanupGlobalOpenCodePlugin(); - - createNotifyScript(); - createClaudeSettingsJson(); - createClaudeWrapper(); - createCodexHooksJson(); - createCodexWrapper(); - createDroidWrapper(); - createDroidSettingsJson(); - createOpenCodePlugin(); - createOpenCodeWrapper(); - createCursorHookScript(); - createCursorAgentWrapper(); - createCursorHooksJson(); - createGeminiHookScript(); - createGeminiWrapper(); - createGeminiSettingsJson(); - createMastraWrapper(); - createMastraHooksJson(); - createCopilotHookScript(); - createCopilotWrapper(); + setupDesktopAgentCapabilities(); createZshWrapper(); createBashWrapper(); diff --git a/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts index 30a73a4f631..a38d404c3eb 100644 --- a/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts @@ -1,7 +1,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { SUPERSET_MANAGED_BINARIES } from "./agent-wrappers-common"; +import { + SUPERSET_MANAGED_BINARIES, + type SupersetManagedBinary, +} from "./desktop-agent-capabilities"; import { BASH_DIR, BIN_DIR, ZSH_DIR } from "./paths"; export interface ShellWrapperPaths { @@ -85,7 +88,7 @@ function buildManagedCommandPrelude(shellName: string, binDir: string): string { if (shellName === "fish") { const escapedBinDir = escapeFishDoubleQuoted(binDir); return SUPERSET_MANAGED_BINARIES.map( - (name) => + (name: SupersetManagedBinary) => `functions -q ${name}; and functions -e ${name} function ${name} set -l _superset_wrapper "${escapedBinDir}/${name}" @@ -99,7 +102,7 @@ end`, } return SUPERSET_MANAGED_BINARIES.map( - (name) => + (name: SupersetManagedBinary) => `unalias ${name} 2>/dev/null || true ${name}() { _superset_wrapper=${quoteShellLiteral(`${binDir}/${name}`)} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/AgentCard.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/AgentCard.tsx index c659b912290..fdbda0b4b5b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/AgentCard.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/AgentCard.tsx @@ -27,11 +27,20 @@ export function AgentCard({ showTaskPrompts, }: AgentCardProps) { const utils = electronTrpc.useUtils(); + const isCustomTerminalAgent = + preset.source === "user" && preset.kind === "terminal"; const updatePreset = electronTrpc.settings.updateAgentPreset.useMutation({ onSuccess: async () => { await utils.settings.getAgentPresets.invalidate(); }, }); + const updateCustomAgent = electronTrpc.settings.updateCustomAgent.useMutation( + { + onSuccess: async () => { + await utils.settings.getAgentPresets.invalidate(); + }, + }, + ); const resetPreset = electronTrpc.settings.resetAgentPreset.useMutation({ onSuccess: async () => { await utils.settings.getAgentPresets.invalidate(); @@ -65,6 +74,11 @@ export function AgentCard({ setInputVersion((current) => current + 1); }; + const isMutating = + updatePreset.isPending || + updateCustomAgent.isPending || + resetPreset.isPending; + const mergePresetPatch = ( currentPreset: ResolvedAgentConfig, patch: AgentPresetPatch, @@ -116,10 +130,23 @@ export function AgentCard({ ); try { - const updatedPreset = await updatePreset.mutateAsync({ - id: preset.id, - patch, - }); + const updatedPreset = isCustomTerminalAgent + ? await updateCustomAgent.mutateAsync({ + id: preset.id, + patch: { + enabled: patch.enabled, + label: patch.label, + description: patch.description, + command: patch.command, + promptCommand: patch.promptCommand, + promptCommandSuffix: patch.promptCommandSuffix, + taskPromptTemplate: patch.taskPromptTemplate, + }, + }) + : await updatePreset.mutateAsync({ + id: preset.id, + patch, + }); if (updatedPreset) { utils.settings.getAgentPresets.setData(undefined, (currentPresets) => currentPresets?.map((candidate) => @@ -191,7 +218,7 @@ export function AgentCard({ isOpen={isOpen} showEnabled={showEnabled} enabled={preset.enabled} - isUpdatingEnabled={updatePreset.isPending || resetPreset.isPending} + isUpdatingEnabled={isMutating} onEnabledChange={handleEnabledChange} onToggle={() => handleOpenChange(!isOpen)} /> @@ -214,10 +241,9 @@ export function AgentCard({ onToggle={() => setShowPreview((current) => !current)} /> - + {preset.source === "builtin" && ( + + )} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/agent-card.utils.test.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/agent-card.utils.test.ts new file mode 100644 index 00000000000..bcf42e46f64 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/agent-card.utils.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import type { ResolvedAgentConfig } from "shared/utils/agent-settings"; +import { buildAgentFieldPatch } from "./agent-card.utils"; + +const BUILTIN_TERMINAL_PRESET: ResolvedAgentConfig = { + id: "claude", + source: "builtin", + kind: "terminal", + label: "Claude Code", + command: "claude", + promptCommand: "claude --print", + promptTransport: "argv", + taskPromptTemplate: "Task {{slug}}", + enabled: true, + overriddenFields: [], +}; + +const CUSTOM_TERMINAL_PRESET: ResolvedAgentConfig = { + id: "custom:team-agent", + source: "user", + kind: "terminal", + label: "Team Agent", + command: "team-agent", + promptCommand: "team-agent --prompt", + promptTransport: "argv", + taskPromptTemplate: "Task {{slug}}", + enabled: true, + overriddenFields: [], +}; + +describe("buildAgentFieldPatch", () => { + test("allows clearing the prompt command for custom terminal agents", () => { + expect( + buildAgentFieldPatch({ + preset: CUSTOM_TERMINAL_PRESET, + field: "promptCommand", + value: " ", + }), + ).toEqual({ + patch: { + promptCommand: "", + }, + }); + }); + + test("keeps prompt command required for builtin terminal agents", () => { + expect( + buildAgentFieldPatch({ + preset: BUILTIN_TERMINAL_PRESET, + field: "promptCommand", + value: " ", + }), + ).toEqual({ + error: "Prompt command is required for terminal agents.", + }); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/agent-card.utils.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/agent-card.utils.ts index ee5dbf5c328..985738d668d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/agent-card.utils.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/agent-card.utils.ts @@ -100,7 +100,9 @@ export function buildAgentFieldPatch({ }; } if (!value.trim()) { - return { error: "Prompt command is required for terminal agents." }; + return preset.source === "user" + ? { patch: { promptCommand: "" } } + : { error: "Prompt command is required for terminal agents." }; } return { patch: { promptCommand: value } }; case "promptCommandSuffix": diff --git a/apps/desktop/src/shared/utils/agent-launch-request.test.ts b/apps/desktop/src/shared/utils/agent-launch-request.test.ts index 0b880eeab2f..de4bc0b4d63 100644 --- a/apps/desktop/src/shared/utils/agent-launch-request.test.ts +++ b/apps/desktop/src/shared/utils/agent-launch-request.test.ts @@ -85,6 +85,28 @@ describe("buildPromptAgentLaunchRequest", () => { }, }); }); + + test("builds Amp prompt launches in interactive stdin mode", () => { + const configsById = indexResolvedAgentConfigs(resolveAgentConfigs({})); + const request = buildPromptAgentLaunchRequest({ + workspaceId: "workspace-1", + source: "new-workspace", + selectedAgent: "amp", + prompt: "wasssup", + configsById, + }); + + expect(request).toMatchObject({ + kind: "terminal", + agentType: "amp", + }); + expect(request?.kind).toBe("terminal"); + if (request?.kind !== "terminal") { + throw new Error("Expected terminal launch request"); + } + expect(request.terminal.command).toStartWith("amp <<'SUPERSET_PROMPT_"); + expect(request.terminal.command).not.toContain("amp -x"); + }); }); describe("buildTaskAgentLaunchRequest", () => { @@ -167,6 +189,34 @@ describe("buildTaskAgentLaunchRequest", () => { }); }); + test("builds Amp task launches in interactive stdin mode", () => { + const configsById = indexResolvedAgentConfigs(resolveAgentConfigs({})); + const request = buildTaskAgentLaunchRequest({ + workspaceId: "workspace-1", + source: "open-in-workspace", + selectedAgent: "amp", + task: TASK, + autoRun: false, + configsById, + }); + + expect(request).toMatchObject({ + kind: "terminal", + agentType: "amp", + terminal: { + taskPromptFileName: "task-demo-task.md", + autoExecute: false, + }, + }); + expect(request?.kind).toBe("terminal"); + if (request?.kind !== "terminal") { + throw new Error("Expected terminal launch request"); + } + expect(request.terminal.command).toBe( + "amp < '.superset/task-demo-task.md'", + ); + }); + test("rejects disabled agents", () => { const configsById = indexResolvedAgentConfigs( resolveAgentConfigs({ diff --git a/apps/desktop/src/shared/utils/agent-settings.test.ts b/apps/desktop/src/shared/utils/agent-settings.test.ts index 5b5c671c8f2..47128fe828f 100644 --- a/apps/desktop/src/shared/utils/agent-settings.test.ts +++ b/apps/desktop/src/shared/utils/agent-settings.test.ts @@ -1,8 +1,11 @@ import { describe, expect, test } from "bun:test"; import { getBuiltinAgentDefinition } from "@superset/shared/agent-catalog"; import { + applyCustomAgentDefinitionPatch, createOverrideEnvelopeWithPatch, + deleteCustomAgentDefinition, resolveAgentConfigs, + upsertCustomAgentDefinition, } from "./agent-settings"; describe("resolveAgentConfigs", () => { @@ -60,6 +63,83 @@ describe("resolveAgentConfigs", () => { enabled: true, }); }); + + test("uses amp as the built-in prompt command for Amp", () => { + const amp = resolveAgentConfigs({}).find((preset) => preset.id === "amp"); + + expect(amp).toMatchObject({ + id: "amp", + kind: "terminal", + command: "amp", + promptCommand: "amp", + enabled: true, + }); + }); + + test("includes custom terminal configs from stored definitions", () => { + const custom = resolveAgentConfigs({ + customDefinitions: [ + { + id: "custom:team-agent", + kind: "terminal", + label: "Team Agent", + description: "Team wrapper", + command: "team-agent", + promptTransport: "stdin", + taskPromptTemplate: "Task {{slug}}", + enabled: false, + }, + ], + }).find((preset) => preset.id === "custom:team-agent"); + + expect(custom).toMatchObject({ + id: "custom:team-agent", + source: "user", + kind: "terminal", + label: "Team Agent", + command: "team-agent", + promptCommand: "team-agent", + promptTransport: "stdin", + taskPromptTemplate: "Task {{slug}}", + enabled: false, + }); + }); + + test("ignores legacy overrides for custom terminal configs", () => { + const custom = resolveAgentConfigs({ + customDefinitions: [ + { + id: "custom:team-agent", + kind: "terminal", + label: "Team Agent", + command: "team-agent", + taskPromptTemplate: "Task {{slug}}", + }, + ], + overrideEnvelope: { + version: 1, + presets: [ + { + id: "custom:team-agent", + label: "Stale Override", + command: "stale-command", + promptCommand: "stale-command --prompt", + enabled: false, + }, + ], + }, + }).find((preset) => preset.id === "custom:team-agent"); + + expect(custom).toMatchObject({ + id: "custom:team-agent", + source: "user", + label: "Team Agent", + command: "team-agent", + promptCommand: "team-agent", + enabled: true, + overriddenFields: [], + }); + }); }); describe("createOverrideEnvelopeWithPatch", () => { @@ -73,7 +153,7 @@ describe("createOverrideEnvelopeWithPatch", () => { }, id: "claude", patch: { - label: definition.defaultLabel, + label: definition.label, description: null, }, }); @@ -122,3 +202,69 @@ describe("createOverrideEnvelopeWithPatch", () => { }); }); }); + +describe("custom agent definition helpers", () => { + test("upserts and patches custom definitions", () => { + const created = upsertCustomAgentDefinition({ + currentDefinitions: [], + definition: { + id: "custom:team-agent", + kind: "terminal", + label: "Team Agent", + command: "team-agent", + taskPromptTemplate: "Task {{slug}}", + }, + }); + const createdDefinition = created[0]; + + if (!createdDefinition) { + throw new Error("Expected custom agent definition to be created"); + } + + const updated = applyCustomAgentDefinitionPatch({ + definition: createdDefinition, + patch: { + description: "Shared team wrapper", + promptCommandSuffix: "--yolo", + promptTransport: "stdin", + enabled: false, + }, + }); + + expect(updated).toMatchObject({ + id: "custom:team-agent", + description: "Shared team wrapper", + promptCommandSuffix: "--yolo", + promptTransport: "stdin", + enabled: false, + }); + }); + + test("deletes custom definitions by id", () => { + const definitions = deleteCustomAgentDefinition({ + currentDefinitions: [ + { + id: "custom:keep", + kind: "terminal", + label: "Keep", + command: "keep", + taskPromptTemplate: "Task {{slug}}", + }, + { + id: "custom:remove", + kind: "terminal", + label: "Remove", + command: "remove", + taskPromptTemplate: "Task {{slug}}", + }, + ], + id: "custom:remove", + }); + + expect(definitions).toEqual([ + expect.objectContaining({ + id: "custom:keep", + }), + ]); + }); +}); diff --git a/apps/desktop/src/shared/utils/agent-settings.ts b/apps/desktop/src/shared/utils/agent-settings.ts index ecfaa50cc80..c13e88ae28d 100644 --- a/apps/desktop/src/shared/utils/agent-settings.ts +++ b/apps/desktop/src/shared/utils/agent-settings.ts @@ -10,10 +10,17 @@ import { type AgentDefinition, type AgentDefinitionId, BUILTIN_AGENT_DEFINITIONS, + type ChatAgentDefinition, isTerminalAgentDefinition, type TerminalAgentDefinition, } from "@superset/shared/agent-catalog"; import type { TaskInput } from "@superset/shared/agent-command"; +import { createTerminalAgentDefinition } from "@superset/shared/agent-definition"; +import { + buildPromptCommandString, + buildPromptFileCommandString, + type PromptTransport, +} from "@superset/shared/agent-prompt-launch"; import { DEFAULT_CHAT_TASK_PROMPT_TEMPLATE, DEFAULT_TERMINAL_TASK_PROMPT_TEMPLATE, @@ -45,29 +52,16 @@ const EMPTY_AGENT_PRESET_OVERRIDE_ENVELOPE: AgentPresetOverrideEnvelope = { presets: [], }; -export type TerminalResolvedAgentConfig = { +export type TerminalResolvedAgentConfig = Omit< + TerminalAgentDefinition, + "id" +> & { id: AgentDefinitionId; - source: "builtin" | "user"; - kind: "terminal"; - label: string; - description?: string; - enabled: boolean; - command: string; - promptCommand: string; - promptCommandSuffix?: string; - taskPromptTemplate: string; overriddenFields: AgentPresetField[]; }; -export type ChatResolvedAgentConfig = { +export type ChatResolvedAgentConfig = Omit & { id: AgentDefinitionId; - source: "builtin" | "user"; - kind: "chat"; - label: string; - description?: string; - enabled: boolean; - taskPromptTemplate: string; - model?: string; overriddenFields: AgentPresetField[]; }; @@ -86,29 +80,58 @@ export type AgentPresetPatch = Partial<{ model: string | null; }>; -function toCustomAgentDefinition( +export type CustomAgentDefinitionPatch = Partial<{ + enabled: boolean; + label: string; + description: string | null; + command: string; + promptCommand: string | null; + promptCommandSuffix: string | null; + promptTransport: PromptTransport | null; + taskPromptTemplate: string; +}>; + +function toUserTerminalAgentDefinition( customDefinition: AgentCustomDefinition, ): TerminalAgentDefinition { - return { + return createTerminalAgentDefinition({ id: customDefinition.id as `custom:${string}`, source: "user", kind: "terminal", - defaultLabel: customDefinition.label, - defaultDescription: customDefinition.description, - defaultCommand: customDefinition.command, - defaultPromptCommand: customDefinition.promptCommand, - defaultPromptCommandSuffix: customDefinition.promptCommandSuffix, - defaultTaskPromptTemplate: customDefinition.taskPromptTemplate, - defaultEnabled: customDefinition.enabled ?? true, - }; + label: customDefinition.label, + description: customDefinition.description, + command: customDefinition.command, + promptCommand: customDefinition.promptCommand, + promptCommandSuffix: customDefinition.promptCommandSuffix, + promptTransport: customDefinition.promptTransport, + taskPromptTemplate: customDefinition.taskPromptTemplate, + enabled: customDefinition.enabled ?? true, + }); } -function readCustomDefinitions( +function canonicalizeCustomAgentDefinition( + definition: AgentCustomDefinition, +): AgentCustomDefinition { + const nextDefinition: AgentCustomDefinition = { ...definition }; + + if (nextDefinition.promptCommand === nextDefinition.command) { + nextDefinition.promptCommand = undefined; + } + if (nextDefinition.promptTransport === "argv") { + nextDefinition.promptTransport = undefined; + } + + return agentCustomDefinitionSchema.parse(nextDefinition); +} + +export function readAgentCustomDefinitions( customDefinitions: AgentCustomDefinition[] | null | undefined, ): AgentCustomDefinition[] { return (customDefinitions ?? []).flatMap((definition) => { const parsed = agentCustomDefinitionSchema.safeParse(definition); - return parsed.success ? [parsed.data] : []; + return parsed.success + ? [canonicalizeCustomAgentDefinition(parsed.data)] + : []; }); } @@ -126,12 +149,104 @@ export function getAgentDefinitions( ): AgentDefinition[] { return [ ...BUILTIN_AGENT_DEFINITIONS, - ...readCustomDefinitions(customDefinitions).map((definition) => - toCustomAgentDefinition(definition), + ...readAgentCustomDefinitions(customDefinitions).map((definition) => + toUserTerminalAgentDefinition(definition), ), ]; } +export function getCustomAgentDefinitionById({ + customDefinitions, + id, +}: { + customDefinitions?: AgentCustomDefinition[] | null; + id: `custom:${string}`; +}): AgentCustomDefinition | null { + return ( + readAgentCustomDefinitions(customDefinitions).find( + (definition) => definition.id === id, + ) ?? null + ); +} + +export function upsertCustomAgentDefinition({ + currentDefinitions, + definition, +}: { + currentDefinitions?: AgentCustomDefinition[] | null; + definition: AgentCustomDefinition; +}): AgentCustomDefinition[] { + const definitions = readAgentCustomDefinitions(currentDefinitions); + const nextDefinition = canonicalizeCustomAgentDefinition( + agentCustomDefinitionSchema.parse(definition), + ); + const index = definitions.findIndex( + (candidate) => candidate.id === nextDefinition.id, + ); + if (index === -1) { + return [...definitions, nextDefinition]; + } + + return definitions.map((candidate, candidateIndex) => + candidateIndex === index ? nextDefinition : candidate, + ); +} + +export function applyCustomAgentDefinitionPatch({ + definition, + patch, +}: { + definition: AgentCustomDefinition; + patch: CustomAgentDefinitionPatch; +}): AgentCustomDefinition { + const nextDefinition: AgentCustomDefinition = { ...definition }; + + if (Object.hasOwn(patch, "enabled")) { + nextDefinition.enabled = patch.enabled; + } + if (Object.hasOwn(patch, "label") && patch.label !== undefined) { + nextDefinition.label = patch.label; + } + if (Object.hasOwn(patch, "description")) { + nextDefinition.description = patch.description ?? undefined; + } + if (Object.hasOwn(patch, "command") && patch.command !== undefined) { + nextDefinition.command = patch.command; + } + if ( + Object.hasOwn(patch, "promptCommand") && + patch.promptCommand !== undefined + ) { + nextDefinition.promptCommand = patch.promptCommand ?? undefined; + } + if (Object.hasOwn(patch, "promptCommandSuffix")) { + nextDefinition.promptCommandSuffix = patch.promptCommandSuffix ?? undefined; + } + if (Object.hasOwn(patch, "promptTransport")) { + nextDefinition.promptTransport = patch.promptTransport ?? undefined; + } + if ( + Object.hasOwn(patch, "taskPromptTemplate") && + patch.taskPromptTemplate !== undefined + ) { + nextDefinition.taskPromptTemplate = patch.taskPromptTemplate; + } + + return agentCustomDefinitionSchema.parse(nextDefinition); +} + +export function deleteCustomAgentDefinition({ + currentDefinitions, + id, +}: { + currentDefinitions?: AgentCustomDefinition[] | null; + id: `custom:${string}`; +}): AgentCustomDefinition[] { + return readAgentCustomDefinitions(currentDefinitions).filter( + (definition) => definition.id !== id, + ); +} + function getOverriddenFields( override: AgentPresetOverride | undefined, definition: AgentDefinition, @@ -147,11 +262,11 @@ function getOverriddenFields( } function resolveDescription( - defaultDescription: string | undefined, + description: string | undefined, override: AgentPresetOverride | undefined, ): string | undefined { if (!override || !Object.hasOwn(override, "description")) { - return defaultDescription; + return description; } return override.description ?? undefined; @@ -169,11 +284,11 @@ function resolvePromptCommandSuffix( } function resolveModel( - defaultModel: string | undefined, + model: string | undefined, override: AgentPresetOverride | undefined, ): string | undefined { if (!override || !Object.hasOwn(override, "model")) { - return defaultModel; + return model; } return override.model?.trim() || undefined; @@ -185,34 +300,32 @@ function resolveAgentConfig( ): ResolvedAgentConfig { if (isTerminalAgentDefinition(definition)) { return { - id: definition.id, - source: definition.source, - kind: "terminal", - label: override?.label ?? definition.defaultLabel, - description: resolveDescription(definition.defaultDescription, override), - enabled: override?.enabled ?? definition.defaultEnabled, - command: override?.command ?? definition.defaultCommand, - promptCommand: override?.promptCommand ?? definition.defaultPromptCommand, + ...definition, + id: definition.id as AgentDefinitionId, + label: override?.label ?? definition.label, + description: resolveDescription(definition.description, override), + enabled: override?.enabled ?? definition.enabled, + command: override?.command ?? definition.command, + promptCommand: override?.promptCommand ?? definition.promptCommand, promptCommandSuffix: resolvePromptCommandSuffix( - definition.defaultPromptCommandSuffix, + definition.promptCommandSuffix, override, ), taskPromptTemplate: - override?.taskPromptTemplate ?? definition.defaultTaskPromptTemplate, + override?.taskPromptTemplate ?? definition.taskPromptTemplate, overriddenFields: getOverriddenFields(override, definition), }; } return { - id: definition.id, - source: definition.source, - kind: "chat", - label: override?.label ?? definition.defaultLabel, - description: resolveDescription(definition.defaultDescription, override), - enabled: override?.enabled ?? definition.defaultEnabled, + ...definition, + id: definition.id as AgentDefinitionId, + label: override?.label ?? definition.label, + description: resolveDescription(definition.description, override), + enabled: override?.enabled ?? definition.enabled, taskPromptTemplate: - override?.taskPromptTemplate ?? definition.defaultTaskPromptTemplate, - model: resolveModel(definition.defaultModel, override), + override?.taskPromptTemplate ?? definition.taskPromptTemplate, + model: resolveModel(definition.model, override), overriddenFields: getOverriddenFields(override, definition), }; } @@ -232,7 +345,12 @@ export function resolveAgentConfigs({ ); return getAgentDefinitions(customDefinitions).map((definition) => - resolveAgentConfig(definition, overridesById.get(definition.id)), + resolveAgentConfig( + definition, + definition.source === "builtin" + ? overridesById.get(definition.id) + : undefined, + ), ); } @@ -274,30 +392,6 @@ export function getFallbackAgentId( return preferredClaude?.id ?? enabledConfigs[0]?.id ?? null; } -function buildHeredoc( - prompt: string, - delimiter: string, - command: string, - suffix?: string, -): string { - const closing = suffix ? `)" ${suffix}` : ')"'; - return [ - `${command} "$(cat <<'${delimiter}'`, - prompt, - delimiter, - closing, - ].join("\n"); -} - -function buildFileCommand( - filePath: string, - command: string, - suffix?: string, -): string { - const escapedPath = filePath.replaceAll("'", "'\\''"); - return `${command} "$(cat '${escapedPath}')"${suffix ? ` ${suffix}` : ""}`; -} - export function getCommandFromAgentConfig( config: TerminalResolvedAgentConfig, ): string | null { @@ -317,13 +411,13 @@ export function buildPromptCommandFromAgentConfig({ const promptCommand = config.promptCommand.trim() || config.command.trim(); if (!promptCommand) return null; - let delimiter = `SUPERSET_PROMPT_${randomId.replaceAll("-", "")}`; - while (prompt.includes(delimiter)) { - delimiter = `${delimiter}_X`; - } - - const suffix = config.promptCommandSuffix?.trim() || undefined; - return buildHeredoc(prompt, delimiter, promptCommand, suffix); + return buildPromptCommandString({ + prompt, + randomId, + command: promptCommand, + suffix: config.promptCommandSuffix?.trim() || undefined, + transport: config.promptTransport, + }); } export function buildFileCommandFromAgentConfig({ @@ -336,8 +430,12 @@ export function buildFileCommandFromAgentConfig({ const promptCommand = config.promptCommand.trim() || config.command.trim(); if (!promptCommand) return null; - const suffix = config.promptCommandSuffix?.trim() || undefined; - return buildFileCommand(filePath, promptCommand, suffix); + return buildPromptFileCommandString({ + filePath, + command: promptCommand, + suffix: config.promptCommandSuffix?.trim() || undefined, + transport: config.promptTransport, + }); } export function buildDefaultTerminalTaskPrompt(task: TaskInput): string { @@ -390,17 +488,13 @@ export function createOverrideEnvelopeWithPatch({ Object.hasOwn(patch, field); if (hasField("enabled")) { - setOrDelete( - "enabled", - patch.enabled, - patch.enabled !== definition.defaultEnabled, - ); + setOrDelete("enabled", patch.enabled, patch.enabled !== definition.enabled); } if (hasField("label")) { - setOrDelete("label", patch.label, patch.label !== definition.defaultLabel); + setOrDelete("label", patch.label, patch.label !== definition.label); } if (hasField("description")) { - const defaultDescription = definition.defaultDescription; + const defaultDescription = definition.description; const shouldPersist = patch.description === null ? defaultDescription !== undefined @@ -411,7 +505,7 @@ export function createOverrideEnvelopeWithPatch({ setOrDelete( "taskPromptTemplate", patch.taskPromptTemplate, - patch.taskPromptTemplate !== definition.defaultTaskPromptTemplate, + patch.taskPromptTemplate !== definition.taskPromptTemplate, ); } @@ -420,21 +514,21 @@ export function createOverrideEnvelopeWithPatch({ setOrDelete( "command", patch.command, - patch.command !== definition.defaultCommand, + patch.command !== definition.command, ); } if (hasField("promptCommand")) { setOrDelete( "promptCommand", patch.promptCommand, - patch.promptCommand !== definition.defaultPromptCommand, + patch.promptCommand !== definition.promptCommand, ); } if (hasField("promptCommandSuffix")) { const shouldPersist = patch.promptCommandSuffix === null - ? definition.defaultPromptCommandSuffix !== undefined - : patch.promptCommandSuffix !== definition.defaultPromptCommandSuffix; + ? definition.promptCommandSuffix !== undefined + : patch.promptCommandSuffix !== definition.promptCommandSuffix; setOrDelete( "promptCommandSuffix", patch.promptCommandSuffix, @@ -444,8 +538,8 @@ export function createOverrideEnvelopeWithPatch({ } else if (hasField("model")) { const shouldPersist = patch.model === null - ? definition.defaultModel !== undefined - : patch.model !== definition.defaultModel; + ? definition.model !== undefined + : patch.model !== definition.model; setOrDelete("model", patch.model ?? undefined, shouldPersist); } diff --git a/apps/desktop/test-setup.ts b/apps/desktop/test-setup.ts index f9c6f088c21..62fed566507 100644 --- a/apps/desktop/test-setup.ts +++ b/apps/desktop/test-setup.ts @@ -175,8 +175,9 @@ const agentCustomDefinitionSchema = z.object({ label: z.string(), description: z.string().optional(), command: z.string(), - promptCommand: z.string(), + promptCommand: z.string().optional(), promptCommandSuffix: z.string().optional(), + promptTransport: z.enum(["argv", "stdin"]).optional(), taskPromptTemplate: z.string(), enabled: z.boolean().optional(), }); @@ -194,6 +195,7 @@ const localDbMock = () => ({ agentPresetOverrideSchema, agentPresetOverrideEnvelopeSchema, agentCustomDefinitionSchema, + PROMPT_TRANSPORTS: ["argv", "stdin"], EXTERNAL_APPS: [], EXECUTION_MODES: ["sequential", "parallel"], BRANCH_PREFIX_MODES: ["none", "github", "author", "custom"], diff --git a/apps/docs/content/docs/agent-integration.mdx b/apps/docs/content/docs/agent-integration.mdx index 6461bc89b6e..5b8a9fad693 100644 --- a/apps/docs/content/docs/agent-integration.mdx +++ b/apps/docs/content/docs/agent-integration.mdx @@ -11,6 +11,7 @@ Run AI coding agents in isolated workspaces. Each agent works independently with ## Supported Agents +- **Amp** - Amp Code CLI - **Claude Code** - Anthropic's CLI - **Codex** - OpenAI's assistant - **OpenCode** - Open-source alternative diff --git a/apps/docs/content/docs/mcp.mdx b/apps/docs/content/docs/mcp.mdx index 38730a3536f..afa52a67af8 100644 --- a/apps/docs/content/docs/mcp.mdx +++ b/apps/docs/content/docs/mcp.mdx @@ -15,13 +15,18 @@ Superset provides an [MCP (Model Context Protocol)](https://modelcontextprotocol | **Workspaces** | Create, update, switch, delete, list, navigate workspaces | | **Devices** | List devices, projects, and app context | | **Organization** | List members and task statuses | -| **AI Sessions** | Start autonomous AI agent sessions (Claude, Codex, Gemini, OpenCode, Pi, Copilot, Cursor Agent) and subagents | +| **AI Sessions** | Start autonomous AI agent sessions (Amp, Claude, Codex, Gemini, OpenCode, Pi, Copilot, Cursor Agent) and subagents | ## Setup ### CLI Options - + + +```bash title="terminal" +amp mcp add --workspace superset https://api.superset.sh/api/agent/mcp +``` + ```bash title="terminal" claude mcp add superset --transport http https://api.superset.sh/api/agent/mcp @@ -72,7 +77,20 @@ opencode mcp add Alternatively, you can manually configure the MCP server for each client: - + + +Add to `.amp/settings.json` in your project root: + +```json title=".amp/settings.json" +{ + "amp.mcpServers": { + "superset": { + "url": "https://api.superset.sh/api/agent/mcp" + } + } +} +``` + Add a `.mcp.json` to your project root. Claude Code auto-discovers this file and handles OAuth authentication. @@ -239,12 +257,12 @@ API keys grant full access to your organization. Keep them secret and never comm | Tool | Description | |------|-------------| -| `start_agent_session` | Start an autonomous AI agent session for a task in an existing workspace. Requires `taskId`. Supports Claude, Codex, Gemini, OpenCode, Pi, Copilot, Cursor Agent, and Superset Chat (defaults to Claude). When `paneId` is provided, adds a new pane to the tab containing that pane instead of initializing a new tab. | -| `start_agent_session_with_prompt` | Start an autonomous AI agent session in an existing workspace using a direct `prompt` instead of a task. Supports Claude, Codex, Gemini, OpenCode, Pi, Copilot, Cursor Agent, and Superset Chat (defaults to Claude). When `paneId` is provided, adds a new pane to the tab containing that pane instead of initializing a new tab. | +| `start_agent_session` | Start an autonomous AI agent session for a task in an existing workspace. Requires `taskId`. Supports Amp, Claude, Codex, Gemini, OpenCode, Pi, Copilot, Cursor Agent, and Superset Chat (defaults to Claude). When `paneId` is provided, adds a new pane to the tab containing that pane instead of initializing a new tab. | +| `start_agent_session_with_prompt` | Start an autonomous AI agent session in an existing workspace using a direct `prompt` instead of a task. Supports Amp, Claude, Codex, Gemini, OpenCode, Pi, Copilot, Cursor Agent, and Superset Chat (defaults to Claude). When `paneId` is provided, adds a new pane to the tab containing that pane instead of initializing a new tab. | ## Chat Integration -In the built-in chat panel, use the `/mcp` slash command to see your workspace's configured MCP servers — their names, transport type (local/remote), and current state (enabled/disabled/invalid). This reads from your workspace MCP config (`.mastracode/mcp.json` or `.mcp.json`). +In the built-in chat panel, use the `/mcp` slash command to see your workspace's configured MCP servers — their names, transport type (local/remote), and current state (enabled/disabled/invalid). This reads from your workspace MCP config (`.mastracode/mcp.json`, `.amp/settings.json`, or `.mcp.json`). ## Example Usage diff --git a/apps/docs/content/docs/terminal-presets.mdx b/apps/docs/content/docs/terminal-presets.mdx index 811fcf76256..5baecb51971 100644 --- a/apps/docs/content/docs/terminal-presets.mdx +++ b/apps/docs/content/docs/terminal-presets.mdx @@ -43,6 +43,7 @@ Presets are parallel by default. Pre-configured presets for popular AI agents: +- **amp** - `amp` - **claude** - `claude --dangerously-skip-permissions` - **codex** - Full danger mode with high reasoning effort - **gemini** - `gemini --yolo` diff --git a/packages/chat/src/server/desktop/router/mcp-overview/mcp-overview.test.ts b/packages/chat/src/server/desktop/router/mcp-overview/mcp-overview.test.ts index 0f294920fd2..9b308fe28f3 100644 --- a/packages/chat/src/server/desktop/router/mcp-overview/mcp-overview.test.ts +++ b/packages/chat/src/server/desktop/router/mcp-overview/mcp-overview.test.ts @@ -114,6 +114,83 @@ describe("getMcpOverview", () => { ]); }); + it("reads servers from .amp/settings.json when present", () => { + const cwd = createTempDirectory(); + mkdirSync(join(cwd, ".amp"), { recursive: true }); + writeFileSync( + join(cwd, ".amp", "settings.json"), + JSON.stringify({ + "amp.mcpServers": { + ampRemote: { + url: "https://amp.example.com/mcp", + }, + ampLocalDisabled: { + command: "bun", + args: ["run", "mcp.ts"], + enabled: false, + }, + }, + }), + "utf-8", + ); + + const result = getMcpOverview(cwd); + expect(result.sourcePath).toBe(join(cwd, ".amp", "settings.json")); + expect(result.servers).toEqual([ + { + name: "ampLocalDisabled", + state: "disabled", + transport: "local", + target: "bun run mcp.ts", + }, + { + name: "ampRemote", + state: "enabled", + transport: "remote", + target: "https://amp.example.com/mcp", + }, + ]); + }); + + it("prefers .mcp.json over .amp/settings.json", () => { + const cwd = createTempDirectory(); + mkdirSync(join(cwd, ".amp"), { recursive: true }); + writeFileSync( + join(cwd, ".mcp.json"), + JSON.stringify({ + mcpServers: { + sharedRemote: { + type: "http", + url: "https://shared.example.com/mcp", + }, + }, + }), + "utf-8", + ); + writeFileSync( + join(cwd, ".amp", "settings.json"), + JSON.stringify({ + "amp.mcpServers": { + personalRemote: { + url: "https://personal.example.com/mcp", + }, + }, + }), + "utf-8", + ); + + const result = getMcpOverview(cwd); + expect(result.sourcePath).toBe(join(cwd, ".mcp.json")); + expect(result.servers).toEqual([ + { + name: "sharedRemote", + state: "enabled", + transport: "remote", + target: "https://shared.example.com/mcp", + }, + ]); + }); + it("falls back to .mcp.json when .mastracode/mcp.json is invalid", () => { const cwd = createTempDirectory(); mkdirSync(join(cwd, ".mastracode"), { recursive: true }); @@ -142,4 +219,61 @@ describe("getMcpOverview", () => { }, ]); }); + + it("falls back to .mcp.json when .amp/settings.json is invalid", () => { + const cwd = createTempDirectory(); + mkdirSync(join(cwd, ".amp"), { recursive: true }); + writeFileSync(join(cwd, ".amp", "settings.json"), "{ invalid", "utf-8"); + writeFileSync( + join(cwd, ".mcp.json"), + JSON.stringify({ + mcpServers: { + fallbackRemote: { + type: "http", + url: "https://fallback.example.com/mcp", + }, + }, + }), + "utf-8", + ); + + const result = getMcpOverview(cwd); + expect(result.sourcePath).toBe(join(cwd, ".mcp.json")); + expect(result.servers).toEqual([ + { + name: "fallbackRemote", + state: "enabled", + transport: "remote", + target: "https://fallback.example.com/mcp", + }, + ]); + }); + + it("falls back to .amp/settings.json when .mcp.json is invalid", () => { + const cwd = createTempDirectory(); + mkdirSync(join(cwd, ".amp"), { recursive: true }); + writeFileSync(join(cwd, ".mcp.json"), "{ invalid", "utf-8"); + writeFileSync( + join(cwd, ".amp", "settings.json"), + JSON.stringify({ + "amp.mcpServers": { + ampFallback: { + url: "https://amp.example.com/mcp", + }, + }, + }), + "utf-8", + ); + + const result = getMcpOverview(cwd); + expect(result.sourcePath).toBe(join(cwd, ".amp", "settings.json")); + expect(result.servers).toEqual([ + { + name: "ampFallback", + state: "enabled", + transport: "remote", + target: "https://amp.example.com/mcp", + }, + ]); + }); }); diff --git a/packages/chat/src/server/desktop/router/mcp-overview/mcp-overview.ts b/packages/chat/src/server/desktop/router/mcp-overview/mcp-overview.ts index 7e3f99bfc73..c626f52083d 100644 --- a/packages/chat/src/server/desktop/router/mcp-overview/mcp-overview.ts +++ b/packages/chat/src/server/desktop/router/mcp-overview/mcp-overview.ts @@ -2,12 +2,38 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { z } from "zod"; -const MCP_SETTINGS_FILES = [".mastracode/mcp.json", ".mcp.json"] as const; - const mcpSettingsSchema = z.object({ mcpServers: z.record(z.string(), z.unknown()), }); +const ampMcpSettingsSchema = z.object({ + "amp.mcpServers": z.record(z.string(), z.unknown()), +}); + +const MCP_SETTINGS_FILES = [ + { + relativePath: ".mastracode/mcp.json", + readServers: (parsed: unknown) => { + const result = mcpSettingsSchema.safeParse(parsed); + return result.success ? result.data.mcpServers : null; + }, + }, + { + relativePath: ".mcp.json", + readServers: (parsed: unknown) => { + const result = mcpSettingsSchema.safeParse(parsed); + return result.success ? result.data.mcpServers : null; + }, + }, + { + relativePath: ".amp/settings.json", + readServers: (parsed: unknown) => { + const result = ampMcpSettingsSchema.safeParse(parsed); + return result.success ? result.data["amp.mcpServers"] : null; + }, + }, +] as const; + export type McpServerState = "enabled" | "disabled" | "invalid"; export type McpServerTransport = "remote" | "local" | "unknown"; @@ -29,7 +55,7 @@ function resolveMcpServers(cwd: string): { } { let firstExistingPath: string | null = null; - for (const relativePath of MCP_SETTINGS_FILES) { + for (const { relativePath, readServers } of MCP_SETTINGS_FILES) { const sourcePath = join(cwd, relativePath); if (!existsSync(sourcePath)) { continue; @@ -46,14 +72,14 @@ function resolveMcpServers(cwd: string): { continue; } - const result = mcpSettingsSchema.safeParse(parsed); - if (!result.success) { + const servers = readServers(parsed); + if (!servers) { continue; } return { sourcePath, - servers: result.data.mcpServers, + servers, }; } diff --git a/packages/local-db/src/schema/zod.ts b/packages/local-db/src/schema/zod.ts index a85ca4459aa..83b6f2ded48 100644 --- a/packages/local-db/src/schema/zod.ts +++ b/packages/local-db/src/schema/zod.ts @@ -129,6 +129,10 @@ export const AGENT_PRESET_FIELDS = [ export type AgentPresetField = (typeof AGENT_PRESET_FIELDS)[number]; +export const PROMPT_TRANSPORTS = ["argv", "stdin"] as const; + +export type PromptTransport = (typeof PROMPT_TRANSPORTS)[number]; + export const agentPresetOverrideSchema = z.object({ id: z.string(), enabled: z.boolean().optional(), @@ -158,8 +162,9 @@ export const agentCustomDefinitionSchema = z.object({ label: z.string(), description: z.string().optional(), command: z.string(), - promptCommand: z.string(), + promptCommand: z.string().optional(), promptCommandSuffix: z.string().optional(), + promptTransport: z.enum(PROMPT_TRANSPORTS).optional(), taskPromptTemplate: z.string(), enabled: z.boolean().optional(), }); diff --git a/packages/mcp/src/tools/devices/start-agent-session/shared.ts b/packages/mcp/src/tools/devices/start-agent-session/shared.ts index 58ab03c0465..a706eb885cf 100644 --- a/packages/mcp/src/tools/devices/start-agent-session/shared.ts +++ b/packages/mcp/src/tools/devices/start-agent-session/shared.ts @@ -30,6 +30,20 @@ export type StartAgentSessionToolName = export const nonEmptyString = z.string().trim().min(1); +function describeSupportedAgents(): string { + const quotedAgents = STARTABLE_AGENT_TYPES.map((agent) => `"${agent}"`); + const lastAgent = quotedAgents.at(-1); + if (!lastAgent) { + return 'AI agent to use. Defaults to "claude".'; + } + + if (quotedAgents.length === 1) { + return `AI agent to use: ${lastAgent}. Defaults to "claude".`; + } + + return `AI agent to use: ${quotedAgents.slice(0, -1).join(", ")}, or ${lastAgent}. Defaults to "claude".`; +} + export const commonInputSchemaShape = { deviceId: nonEmptyString.describe("Target device ID"), workspaceId: nonEmptyString.describe( @@ -43,9 +57,7 @@ export const commonInputSchemaShape = { agent: z .enum(STARTABLE_AGENT_TYPES) .optional() - .describe( - 'AI agent to use: "claude", "codex", "gemini", "opencode", "pi", "copilot", "cursor-agent", or "superset-chat". Defaults to "claude".', - ), + .describe(describeSupportedAgents()), }; export const taskInputSchemaShape = { diff --git a/packages/shared/package.json b/packages/shared/package.json index 86f5836a6b8..3f39690ad79 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -24,6 +24,14 @@ "types": "./src/agent-command.ts", "default": "./src/agent-command.ts" }, + "./agent-prompt-launch": { + "types": "./src/agent-prompt-launch.ts", + "default": "./src/agent-prompt-launch.ts" + }, + "./agent-definition": { + "types": "./src/agent-definition.ts", + "default": "./src/agent-definition.ts" + }, "./agent-catalog": { "types": "./src/agent-catalog.ts", "default": "./src/agent-catalog.ts" diff --git a/packages/shared/src/agent-catalog.ts b/packages/shared/src/agent-catalog.ts index fb59087be77..685f7614337 100644 --- a/packages/shared/src/agent-catalog.ts +++ b/packages/shared/src/agent-catalog.ts @@ -1,84 +1,53 @@ +import type { + AgentDefinition, + AgentDefinitionSource, + AgentKind, + ChatAgentDefinition, + TerminalAgentDefinition, +} from "./agent-definition"; +import { DEFAULT_CHAT_TASK_PROMPT_TEMPLATE } from "./agent-prompt-template"; import { - AGENT_LABELS, - AGENT_PRESET_COMMANDS, - AGENT_PRESET_DESCRIPTIONS, - AGENT_PROMPT_COMMANDS, - AGENT_TYPES, - type AgentType, -} from "./agent-command"; -import { - DEFAULT_CHAT_TASK_PROMPT_TEMPLATE, - DEFAULT_TERMINAL_TASK_PROMPT_TEMPLATE, -} from "./agent-prompt-template"; + BUILTIN_TERMINAL_AGENT_TYPES, + BUILTIN_TERMINAL_AGENTS, +} from "./builtin-terminal-agents"; -export const BUILTIN_AGENT_IDS = [...AGENT_TYPES, "superset-chat"] as const; +export const BUILTIN_AGENT_IDS = [ + ...BUILTIN_TERMINAL_AGENT_TYPES, + "superset-chat", +] as const; export type BuiltinAgentId = (typeof BUILTIN_AGENT_IDS)[number]; export type AgentDefinitionId = BuiltinAgentId | `custom:${string}`; -export type AgentDefinitionSource = "builtin" | "user"; -export type AgentKind = "terminal" | "chat"; - -interface BaseAgentDefinition { - id: AgentDefinitionId; - source: AgentDefinitionSource; - kind: AgentKind; - defaultLabel: string; - defaultDescription?: string; - defaultEnabled: boolean; -} - -export interface TerminalAgentDefinition extends BaseAgentDefinition { - kind: "terminal"; - defaultCommand: string; - defaultPromptCommand: string; - defaultPromptCommandSuffix?: string; - defaultTaskPromptTemplate: string; -} - -export interface ChatAgentDefinition extends BaseAgentDefinition { - kind: "chat"; - defaultTaskPromptTemplate: string; - defaultModel?: string; -} -export type AgentDefinition = TerminalAgentDefinition | ChatAgentDefinition; +export type { + AgentDefinition, + AgentDefinitionSource, + AgentKind, + ChatAgentDefinition, + TerminalAgentDefinition, +}; export const BUILTIN_AGENT_LABELS: Record = { - ...AGENT_LABELS, + ...Object.fromEntries( + BUILTIN_TERMINAL_AGENTS.map((agent) => [agent.id, agent.label]), + ), "superset-chat": "Superset Chat", -}; +} as Record; -function createBuiltinTerminalAgentDefinition( - id: AgentType, -): TerminalAgentDefinition { - const promptCommand = AGENT_PROMPT_COMMANDS[id]; - - return { - id, - source: "builtin", - kind: "terminal", - defaultLabel: AGENT_LABELS[id], - defaultDescription: AGENT_PRESET_DESCRIPTIONS[id], - defaultCommand: AGENT_PRESET_COMMANDS[id][0] ?? "", - defaultPromptCommand: promptCommand.command, - defaultPromptCommandSuffix: promptCommand.suffix, - defaultTaskPromptTemplate: DEFAULT_TERMINAL_TASK_PROMPT_TEMPLATE, - defaultEnabled: true, - }; -} +const BUILTIN_CHAT_AGENT: ChatAgentDefinition = { + id: "superset-chat", + source: "builtin", + kind: "chat", + label: "Superset Chat", + description: + "Superset's built-in workspace chat for project-aware help and task launches.", + enabled: true, + taskPromptTemplate: DEFAULT_CHAT_TASK_PROMPT_TEMPLATE, +}; export const BUILTIN_AGENT_DEFINITIONS: AgentDefinition[] = [ - ...AGENT_TYPES.map((id) => createBuiltinTerminalAgentDefinition(id)), - { - id: "superset-chat", - source: "builtin", - kind: "chat", - defaultLabel: BUILTIN_AGENT_LABELS["superset-chat"], - defaultDescription: - "Superset's built-in workspace chat for project-aware help and task launches.", - defaultTaskPromptTemplate: DEFAULT_CHAT_TASK_PROMPT_TEMPLATE, - defaultEnabled: true, - }, + ...BUILTIN_TERMINAL_AGENTS, + BUILTIN_CHAT_AGENT, ]; export function getBuiltinAgentDefinition(id: BuiltinAgentId): AgentDefinition { diff --git a/packages/shared/src/agent-command.test.ts b/packages/shared/src/agent-command.test.ts index 7619707cef0..301f2ff368e 100644 --- a/packages/shared/src/agent-command.test.ts +++ b/packages/shared/src/agent-command.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { buildAgentPromptCommand } from "./agent-command"; +import { + buildAgentFileCommand, + buildAgentPromptCommand, +} from "./agent-command"; describe("buildAgentPromptCommand", () => { it("adds `--` before codex prompt payload", () => { @@ -27,6 +30,26 @@ describe("buildAgentPromptCommand", () => { ); }); + it("uses Amp interactive stdin mode for prompt launches", () => { + const command = buildAgentPromptCommand({ + prompt: "hello", + randomId: "amp-1234", + agent: "amp", + }); + + expect(command).toStartWith("amp <<'SUPERSET_PROMPT_amp1234'"); + expect(command).not.toContain("amp -x"); + }); + + it("uses Amp interactive stdin mode for file launches", () => { + const command = buildAgentFileCommand({ + filePath: ".superset/task-demo.md", + agent: "amp", + }); + + expect(command).toBe("amp < '.superset/task-demo.md'"); + }); + it("uses pi interactive mode for prompt launches", () => { const command = buildAgentPromptCommand({ prompt: "hello", diff --git a/packages/shared/src/agent-command.ts b/packages/shared/src/agent-command.ts index a9feb535400..58704d0d99e 100644 --- a/packages/shared/src/agent-command.ts +++ b/packages/shared/src/agent-command.ts @@ -1,99 +1,49 @@ +import { + buildPromptCommandString, + buildPromptFileCommandString, + type PromptTransport, +} from "./agent-prompt-launch"; import { DEFAULT_TERMINAL_TASK_PROMPT_TEMPLATE, renderTaskPromptTemplate, } from "./agent-prompt-template"; +import { + BUILTIN_TERMINAL_AGENT_COMMANDS, + BUILTIN_TERMINAL_AGENT_DESCRIPTIONS, + BUILTIN_TERMINAL_AGENT_LABELS, + BUILTIN_TERMINAL_AGENT_PROMPT_COMMANDS, + BUILTIN_TERMINAL_AGENT_TYPES, + type BuiltinTerminalAgentType, +} from "./builtin-terminal-agents"; + +export { + BUILTIN_TERMINAL_AGENTS, + DEFAULT_TERMINAL_PRESET_AGENT_TYPES, +} from "./builtin-terminal-agents"; -export const AGENT_TYPES = [ - "claude", - "codex", - "gemini", - "mastracode", - "opencode", - "pi", - "copilot", - "cursor-agent", -] as const; +export const AGENT_TYPES = BUILTIN_TERMINAL_AGENT_TYPES; -export type AgentType = (typeof AGENT_TYPES)[number]; +export type AgentType = BuiltinTerminalAgentType; -export const AGENT_LABELS: Record = { - claude: "Claude", - codex: "Codex", - gemini: "Gemini", - mastracode: "Mastracode", - opencode: "OpenCode", - pi: "Pi", - copilot: "Copilot", - "cursor-agent": "Cursor Agent", -}; +export const AGENT_LABELS: Record = + BUILTIN_TERMINAL_AGENT_LABELS; -export const AGENT_PRESET_COMMANDS: Record = { - claude: ["claude --dangerously-skip-permissions"], - codex: [ - 'codex -c model_reasoning_effort="high" --dangerously-bypass-approvals-and-sandbox -c model_reasoning_summary="detailed" -c model_supports_reasoning_summaries=true', - ], - gemini: ["gemini --yolo"], - mastracode: ["mastracode"], - opencode: ["opencode"], - pi: ["pi"], - copilot: ["copilot --allow-all"], - "cursor-agent": ["cursor-agent"], -}; +export const AGENT_PRESET_COMMANDS: Record = + BUILTIN_TERMINAL_AGENT_COMMANDS; -export const AGENT_PRESET_DESCRIPTIONS: Record = { - claude: - "Anthropic's coding agent for reading code, editing files, and running terminal workflows.", - codex: - "OpenAI's coding agent for reading, modifying, and running code across tasks.", - gemini: - "Google's open-source terminal agent for coding, problem-solving, and task work.", - mastracode: - "Mastra's coding agent for building, debugging, and shipping code from the terminal.", - opencode: "Open-source coding agent for the terminal, IDE, and desktop.", - pi: "Minimal terminal coding harness for flexible coding workflows.", - copilot: - "GitHub's coding agent for planning, editing, and building in your repo.", - "cursor-agent": - "Cursor's coding agent for editing, running, and debugging code in parallel.", -}; +export const AGENT_PRESET_DESCRIPTIONS: Record = + BUILTIN_TERMINAL_AGENT_DESCRIPTIONS; export interface AgentPromptCommandDefaults { command: string; suffix?: string; + transport: PromptTransport; } export const AGENT_PROMPT_COMMANDS: Record< AgentType, AgentPromptCommandDefaults -> = { - claude: { - command: AGENT_PRESET_COMMANDS.claude[0] ?? "claude", - }, - codex: { - command: `${AGENT_PRESET_COMMANDS.codex[0] ?? "codex"} --`, - }, - gemini: { - command: "gemini", - suffix: "--yolo", - }, - mastracode: { - command: AGENT_PRESET_COMMANDS.mastracode[0] ?? "mastracode", - }, - opencode: { - command: "opencode --prompt", - }, - pi: { - command: AGENT_PRESET_COMMANDS.pi[0] ?? "pi", - }, - copilot: { - command: "copilot -i --allow-all", - suffix: "--yolo", - }, - "cursor-agent": { - command: AGENT_PRESET_COMMANDS["cursor-agent"][0] ?? "cursor-agent", - suffix: "--yolo", - }, -}; +> = BUILTIN_TERMINAL_AGENT_PROMPT_COMMANDS; export interface TaskInput { id: string; @@ -109,28 +59,14 @@ export function buildAgentTaskPrompt(task: TaskInput): string { return renderTaskPromptTemplate(DEFAULT_TERMINAL_TASK_PROMPT_TEMPLATE, task); } -function buildHeredoc( - prompt: string, - delimiter: string, - command: string, - suffix?: string, -): string { - const closing = suffix ? `)" ${suffix}` : ')"'; - return [ - `${command} "$(cat <<'${delimiter}'`, - prompt, - delimiter, - closing, - ].join("\n"); -} - -function buildFileCommand( - filePath: string, - command: string, - suffix?: string, -): string { - const escapedPath = filePath.replaceAll("'", "'\\''"); - return `${command} "$(cat '${escapedPath}')"${suffix ? ` ${suffix}` : ""}`; +function getAgentPromptCommandDefaults( + agent: AgentType, +): AgentPromptCommandDefaults { + const promptCommand = AGENT_PROMPT_COMMANDS[agent]; + if (!promptCommand) { + throw new Error(`Unknown agent prompt command defaults: ${agent}`); + } + return promptCommand; } export function buildAgentFileCommand({ @@ -140,12 +76,13 @@ export function buildAgentFileCommand({ filePath: string; agent?: AgentType; }): string { - const promptCommand = AGENT_PROMPT_COMMANDS[agent]; - return buildFileCommand( + const promptCommand = getAgentPromptCommandDefaults(agent); + return buildPromptFileCommandString({ filePath, - promptCommand.command, - promptCommand.suffix, - ); + command: promptCommand.command, + suffix: promptCommand.suffix, + transport: promptCommand.transport, + }); } export function buildAgentPromptCommand({ @@ -157,17 +94,14 @@ export function buildAgentPromptCommand({ randomId: string; agent?: AgentType; }): string { - let delimiter = `SUPERSET_PROMPT_${randomId.replaceAll("-", "")}`; - while (prompt.includes(delimiter)) { - delimiter = `${delimiter}_X`; - } - const promptCommand = AGENT_PROMPT_COMMANDS[agent]; - return buildHeredoc( + const promptCommand = getAgentPromptCommandDefaults(agent); + return buildPromptCommandString({ prompt, - delimiter, - promptCommand.command, - promptCommand.suffix, - ); + randomId, + command: promptCommand.command, + suffix: promptCommand.suffix, + transport: promptCommand.transport, + }); } export function buildAgentCommand({ diff --git a/packages/shared/src/agent-definition.ts b/packages/shared/src/agent-definition.ts new file mode 100644 index 00000000000..9743194f544 --- /dev/null +++ b/packages/shared/src/agent-definition.ts @@ -0,0 +1,57 @@ +import type { PromptTransport } from "./agent-prompt-launch"; + +export type AgentDefinitionSource = "builtin" | "user"; +export type AgentKind = "terminal" | "chat"; + +interface BaseAgentDefinition { + id: string; + source: AgentDefinitionSource; + kind: AgentKind; + label: string; + description?: string; + enabled: boolean; + taskPromptTemplate: string; +} + +export interface TerminalAgentDefinition extends BaseAgentDefinition { + kind: "terminal"; + command: string; + promptCommand: string; + promptCommandSuffix?: string; + promptTransport: PromptTransport; +} + +export interface TerminalAgentDefinitionInput + extends Omit { + promptCommand?: string; + promptTransport?: PromptTransport; +} + +export interface ChatAgentDefinition extends BaseAgentDefinition { + kind: "chat"; + model?: string; +} + +export type AgentDefinition = TerminalAgentDefinition | ChatAgentDefinition; + +export function createTerminalAgentDefinition( + input: TerminalAgentDefinitionInput, +): TerminalAgentDefinition { + return { + ...input, + promptCommand: input.promptCommand ?? input.command, + promptTransport: input.promptTransport ?? "argv", + }; +} + +export function isTerminalAgentDefinition( + definition: AgentDefinition, +): definition is TerminalAgentDefinition { + return definition.kind === "terminal"; +} + +export function isChatAgentDefinition( + definition: AgentDefinition, +): definition is ChatAgentDefinition { + return definition.kind === "chat"; +} diff --git a/packages/shared/src/agent-prompt-launch.ts b/packages/shared/src/agent-prompt-launch.ts new file mode 100644 index 00000000000..f1a33fd95a3 --- /dev/null +++ b/packages/shared/src/agent-prompt-launch.ts @@ -0,0 +1,66 @@ +/** + * Prompt transports define the small set of ways a CLI can receive prompt + * payloads. Keep this enum intentionally small and add a new transport only + * when a real agent requires it. Avoid arbitrary per-agent shell templates. + */ +export type PromptTransport = "argv" | "stdin"; + +function resolveDelimiter(prompt: string, randomId: string): string { + let delimiter = `SUPERSET_PROMPT_${randomId.replaceAll("-", "")}`; + while (prompt.includes(delimiter)) { + delimiter = `${delimiter}_X`; + } + return delimiter; +} + +function quoteSingleShell(value: string): string { + return value.replaceAll("'", "'\\''"); +} + +function joinCommand(command: string, suffix?: string): string { + return suffix ? `${command} ${suffix}` : command; +} + +export function buildPromptCommandString({ + command, + suffix, + transport, + prompt, + randomId, +}: { + command: string; + suffix?: string; + transport: PromptTransport; + prompt: string; + randomId: string; +}): string { + const delimiter = resolveDelimiter(prompt, randomId); + const fullCommand = joinCommand(command, suffix); + + if (transport === "stdin") { + return `${fullCommand} <<'${delimiter}'\n${prompt}\n${delimiter}`; + } + + return `${command} "$(cat <<'${delimiter}'\n${prompt}\n${delimiter}\n)"${suffix ? ` ${suffix}` : ""}`; +} + +export function buildPromptFileCommandString({ + command, + suffix, + transport, + filePath, +}: { + command: string; + suffix?: string; + transport: PromptTransport; + filePath: string; +}): string { + const escapedPath = quoteSingleShell(filePath); + const fullCommand = joinCommand(command, suffix); + + if (transport === "stdin") { + return `${fullCommand} < '${escapedPath}'`; + } + + return `${command} "$(cat '${escapedPath}')"${suffix ? ` ${suffix}` : ""}`; +} diff --git a/packages/shared/src/builtin-terminal-agents.ts b/packages/shared/src/builtin-terminal-agents.ts new file mode 100644 index 00000000000..0f3932df330 --- /dev/null +++ b/packages/shared/src/builtin-terminal-agents.ts @@ -0,0 +1,182 @@ +import { + createTerminalAgentDefinition, + type TerminalAgentDefinition, + type TerminalAgentDefinitionInput, +} from "./agent-definition"; +import type { PromptTransport } from "./agent-prompt-launch"; +import { DEFAULT_TERMINAL_TASK_PROMPT_TEMPLATE } from "./agent-prompt-template"; + +interface BuiltinTerminalAgentManifest + extends Omit< + TerminalAgentDefinitionInput, + "source" | "kind" | "enabled" | "taskPromptTemplate" + > { + description: string; + includeInDefaultTerminalPresets?: boolean; +} + +export interface BuiltinTerminalAgentDefinition + extends TerminalAgentDefinition { + description: string; + includeInDefaultTerminalPresets?: boolean; +} + +type AgentIdTuple = { + [K in keyof T]: T[K] extends { id: infer TId } ? TId : never; +}; + +function mapAgentIds( + agents: T, +): AgentIdTuple { + return agents.map((agent) => agent.id) as AgentIdTuple; +} + +function createAgentRecord( + agents: T, + getValue: (agent: T[number]) => TValue, +): Record { + return Object.fromEntries( + agents.map((agent) => [agent.id, getValue(agent)]), + ) as Record; +} + +function createBuiltinTerminalAgent< + const T extends BuiltinTerminalAgentManifest, +>(manifest: T): BuiltinTerminalAgentDefinition & { id: T["id"] } { + return { + ...createTerminalAgentDefinition({ + ...manifest, + source: "builtin", + kind: "terminal", + enabled: true, + taskPromptTemplate: DEFAULT_TERMINAL_TASK_PROMPT_TEMPLATE, + }), + description: manifest.description, + includeInDefaultTerminalPresets: manifest.includeInDefaultTerminalPresets, + }; +} + +export const BUILTIN_TERMINAL_AGENTS = [ + createBuiltinTerminalAgent({ + id: "claude", + label: "Claude", + description: + "Anthropic's coding agent for reading code, editing files, and running terminal workflows.", + command: "claude --dangerously-skip-permissions", + includeInDefaultTerminalPresets: true, + }), + createBuiltinTerminalAgent({ + id: "amp", + label: "Amp", + description: + "Amp's coding agent for terminal-first coding, subagents, and task work.", + command: "amp", + promptTransport: "stdin", + includeInDefaultTerminalPresets: true, + }), + createBuiltinTerminalAgent({ + id: "codex", + label: "Codex", + description: + "OpenAI's coding agent for reading, modifying, and running code across tasks.", + command: + 'codex -c model_reasoning_effort="high" --dangerously-bypass-approvals-and-sandbox -c model_reasoning_summary="detailed" -c model_supports_reasoning_summaries=true', + promptCommand: + 'codex -c model_reasoning_effort="high" --dangerously-bypass-approvals-and-sandbox -c model_reasoning_summary="detailed" -c model_supports_reasoning_summaries=true --', + includeInDefaultTerminalPresets: true, + }), + createBuiltinTerminalAgent({ + id: "gemini", + label: "Gemini", + description: + "Google's open-source terminal agent for coding, problem-solving, and task work.", + command: "gemini --yolo", + promptCommand: "gemini", + promptCommandSuffix: "--yolo", + includeInDefaultTerminalPresets: true, + }), + createBuiltinTerminalAgent({ + id: "mastracode", + label: "Mastracode", + description: + "Mastra's coding agent for building, debugging, and shipping code from the terminal.", + command: "mastracode", + includeInDefaultTerminalPresets: true, + }), + createBuiltinTerminalAgent({ + id: "opencode", + label: "OpenCode", + description: "Open-source coding agent for the terminal, IDE, and desktop.", + command: "opencode", + promptCommand: "opencode --prompt", + includeInDefaultTerminalPresets: true, + }), + createBuiltinTerminalAgent({ + id: "pi", + label: "Pi", + description: + "Minimal terminal coding harness for flexible coding workflows.", + command: "pi", + includeInDefaultTerminalPresets: true, + }), + createBuiltinTerminalAgent({ + id: "copilot", + label: "Copilot", + description: + "GitHub's coding agent for planning, editing, and building in your repo.", + command: "copilot --allow-all", + promptCommand: "copilot -i --allow-all", + promptCommandSuffix: "--yolo", + includeInDefaultTerminalPresets: true, + }), + createBuiltinTerminalAgent({ + id: "cursor-agent", + label: "Cursor Agent", + description: + "Cursor's coding agent for editing, running, and debugging code in parallel.", + command: "cursor-agent", + promptCommandSuffix: "--yolo", + }), +] as const; + +export type BuiltinTerminalAgentType = + (typeof BUILTIN_TERMINAL_AGENTS)[number]["id"]; + +export const BUILTIN_TERMINAL_AGENT_TYPES = mapAgentIds( + BUILTIN_TERMINAL_AGENTS, +); + +export const BUILTIN_TERMINAL_AGENT_LABELS = createAgentRecord( + BUILTIN_TERMINAL_AGENTS, + (agent) => agent.label, +); + +export const BUILTIN_TERMINAL_AGENT_DESCRIPTIONS = createAgentRecord( + BUILTIN_TERMINAL_AGENTS, + (agent) => agent.description, +); + +export const BUILTIN_TERMINAL_AGENT_COMMANDS = createAgentRecord( + BUILTIN_TERMINAL_AGENTS, + (agent) => [agent.command], +); + +export const BUILTIN_TERMINAL_AGENT_PROMPT_COMMANDS = createAgentRecord( + BUILTIN_TERMINAL_AGENTS, + ( + agent, + ): { + command: string; + suffix?: string; + transport: PromptTransport; + } => ({ + command: agent.promptCommand, + suffix: agent.promptCommandSuffix, + transport: agent.promptTransport, + }), +); + +export const DEFAULT_TERMINAL_PRESET_AGENT_TYPES = + BUILTIN_TERMINAL_AGENTS.filter( + (agent) => agent.includeInDefaultTerminalPresets, + ).map((agent) => agent.id) satisfies BuiltinTerminalAgentType[]; diff --git a/packages/ui/src/assets/icons/preset-icons/amp.svg b/packages/ui/src/assets/icons/preset-icons/amp.svg new file mode 100644 index 00000000000..dde8755df7e --- /dev/null +++ b/packages/ui/src/assets/icons/preset-icons/amp.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/ui/src/assets/icons/preset-icons/index.ts b/packages/ui/src/assets/icons/preset-icons/index.ts index 900210c6b72..635e5913794 100644 --- a/packages/ui/src/assets/icons/preset-icons/index.ts +++ b/packages/ui/src/assets/icons/preset-icons/index.ts @@ -1,3 +1,4 @@ +import ampIcon from "./amp.svg"; import claudeIcon from "./claude.svg"; import codexIcon from "./codex.svg"; import codexWhiteIcon from "./codex-white.svg"; @@ -19,6 +20,7 @@ export interface PresetIconSet { } export const PRESET_ICONS: Record = { + amp: { light: ampIcon, dark: ampIcon }, claude: { light: claudeIcon, dark: claudeIcon }, codex: { light: codexIcon, dark: codexWhiteIcon }, copilot: { light: copilotIcon, dark: copilotWhiteIcon }, @@ -42,6 +44,7 @@ export function getPresetIcon( } export { + ampIcon, claudeIcon, codexIcon, codexWhiteIcon,