diff --git a/bun.lock b/bun.lock index 8d2fe82d78f..d1cffc182cb 100644 --- a/bun.lock +++ b/bun.lock @@ -315,6 +315,7 @@ "bun-pty": "0.4.8", "chokidar": "4.0.3", "clipboardy": "4.0.0", + "cron-parser": "4.9.0", "decimal.js": "10.5.0", "diff": "catalog:", "fuzzysort": "3.1.0", @@ -2271,6 +2272,8 @@ "crc32-stream": ["crc32-stream@6.0.0", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^4.0.0" } }, "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g=="], + "cron-parser": ["cron-parser@4.9.0", "", { "dependencies": { "luxon": "^3.2.1" } }, "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q=="], + "cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 8a111472baf..87b69bd52e7 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -34,6 +34,7 @@ import { Suspense, JSX } from "solid-js" const Home = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) +const Automations = lazy(() => import("@/pages/automations")) const Loading = () =>
function UiI18nBridge(props: ParentProps) { @@ -140,6 +141,14 @@ export function AppInterface(props: { defaultUrl?: string; children?: JSX.Elemen )} /> + ( + }> + + + )} + /> } /> void +} + +export const AutomationDayToggle: Component = (props) => { + const active = () => props.active + const short = () => { + return props.label.slice(0, 2) + } + + const handleClick = () => { + props.onChange(!active()) + } + + return ( + + ) +} diff --git a/packages/app/src/components/automation-time-row.tsx b/packages/app/src/components/automation-time-row.tsx new file mode 100644 index 00000000000..c36fbe53001 --- /dev/null +++ b/packages/app/src/components/automation-time-row.tsx @@ -0,0 +1,80 @@ +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Select } from "@opencode-ai/ui/select" +import { createMemo, type Component } from "solid-js" + +interface TimeRowProps { + index: number + value: string + hours: string[] + minutes: string[] + removeLabel: string + onChange: (value: string) => void + onRemove: () => void +} + +export const AutomationTimeRow: Component = (props) => { + const parts = createMemo(() => props.value.split(":")) + const hour = () => { + const value = parts()[0] ?? "00" + return value.padStart(2, "0") + } + const minute = () => { + const value = parts()[1] ?? "00" + return value.padStart(2, "0") + } + + const setHour = (value?: string) => { + if (!value) return + + props.onChange(`${value}:${minute()}`) + } + + const setMinute = (value?: string) => { + if (!value) return + + props.onChange(`${hour()}:${value}`) + } + + return ( +
+
+ {props.index + 1} +
+
+ value} + label={(value) => value} + onSelect={setMinute} + size="small" + variant="secondary" + valueClass="font-mono text-12-medium" + triggerStyle={{ "min-width": "72px" }} + portal={false} + /> +
+ +
+ ) +} diff --git a/packages/app/src/components/dialog-automation-delete.tsx b/packages/app/src/components/dialog-automation-delete.tsx new file mode 100644 index 00000000000..7a40ba9b6fb --- /dev/null +++ b/packages/app/src/components/dialog-automation-delete.tsx @@ -0,0 +1,22 @@ +import { DialogConfirm } from "@/components/dialog-confirm" +import { useLanguage } from "@/context/language" +import type { Automation } from "@opencode-ai/sdk/v2/client" + +export function DialogAutomationDelete(props: { + automation: Automation + onDelete: (automation: Automation) => Promise +}) { + const language = useLanguage() + + const handleDelete = () => props.onDelete(props.automation) + + return ( + + ) +} diff --git a/packages/app/src/components/dialog-automation.tsx b/packages/app/src/components/dialog-automation.tsx new file mode 100644 index 00000000000..23cc45cb45f --- /dev/null +++ b/packages/app/src/components/dialog-automation.tsx @@ -0,0 +1,760 @@ +import { Button } from "@opencode-ai/ui/button" +import { Checkbox } from "@opencode-ai/ui/checkbox" +import { Dialog } from "@opencode-ai/ui/dialog" +import { RadioGroup } from "@opencode-ai/ui/radio-group" +import { Switch } from "@opencode-ai/ui/switch" +import { TextField } from "@opencode-ai/ui/text-field" +import { DateTime } from "luxon" +import { createEffect, createMemo, createResource, For, on, onCleanup, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useLanguage } from "@/context/language" +import { useGlobalSDK } from "@/context/global-sdk" +import { useGlobalSync } from "@/context/global-sync" +import { usePlatform } from "@/context/platform" +import { useCommand } from "@/context/command" +import { AutomationDayToggle } from "@/components/automation-day-toggle" +import { AutomationTimeRow } from "@/components/automation-time-row" +import { DialogAutomationDelete } from "@/components/dialog-automation-delete" +import { PromptEditor, promptText, type SlashCommand, type TemplateOption } from "@/components/prompt-editor" +import { DEFAULT_PROMPT, type Prompt } from "@/context/prompt" +import { getFilename } from "@opencode-ai/util/path" +import { createOpencodeClient, type Automation, type Project } from "@opencode-ai/sdk/v2/client" + +const dayOrder = ["1", "2", "3", "4", "5", "6", "0"] +const defaultDays = ["1", "2", "3", "4", "5"] +const defaultTimes = ["09:00"] +const hours = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, "0")) +const minutes = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, "0")) + +function sortDays(input: string[]) { + return dayOrder.filter((day) => input.includes(day)) +} + +function parseDays(value: string) { + if (value === "*") return dayOrder + + const parts = value + .split(",") + .map((part) => part.trim()) + .filter(Boolean) + if (parts.length === 0) return + if (parts.includes("*")) return dayOrder + + const values: string[] = [] + for (const part of parts) { + if (/^[0-6]$/.test(part)) { + values.push(part) + continue + } + + if (!/^[0-6]-[0-6]$/.test(part)) continue + + const start = Number(part[0]) + const end = Number(part[2]) + if (start > end) continue + + const items = Array.from({ length: end - start + 1 }, (_, i) => String(start + i)) + values.push(...items) + } + + if (values.length === 0) return + if (values.some((item) => !/^[0-6]$/.test(item))) return + + return sortDays([...new Set(values)]) +} + +function parseTime(hour: string, minute: string) { + if (!/^\d+$/.test(hour) || !/^\d+$/.test(minute)) return + + const h = Number(hour) + const m = Number(minute) + + if (!Number.isFinite(h) || !Number.isFinite(m)) return + if (h < 0 || h > 23) return + if (m < 0 || m > 59) return + + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}` +} + +function promptParts(value: string) { + if (!value) return DEFAULT_PROMPT + + const parts: Prompt = [] + const regex = /@\S+/g + let cursor = 0 + let position = 0 + + const pushText = (content: string) => { + if (!content) return + parts.push({ + type: "text", + content, + start: position, + end: position + content.length, + }) + position += content.length + } + + for (const match of value.matchAll(regex)) { + const mention = match[0] + const start = match.index ?? cursor + const previous = start > 0 ? value[start - 1] : "" + const separated = start === 0 || /\s/.test(previous) + if (!separated) continue + + pushText(value.slice(cursor, start)) + + const path = mention.slice(1) + if (!path) { + pushText(mention) + cursor = start + mention.length + continue + } + + parts.push({ + type: "file", + path, + content: mention, + start: position, + end: position + mention.length, + }) + position += mention.length + cursor = start + mention.length + } + + pushText(value.slice(cursor)) + if (parts.length === 0) { + return [{ type: "text", content: value, start: 0, end: value.length }] satisfies Prompt + } + + return parts +} + +function parseBuilderSchedule(value: string | null | undefined) { + const raw = value?.trim() + if (!raw) return + + const lines = raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + if (lines.length === 0) return + + const times: string[] = [] + let days: string[] | undefined + + for (const line of lines) { + const parts = line.split(/\s+/) + if (parts.length !== 5) return + + const minute = parts[0] + const hour = parts[1] + const day = parts[2] + const month = parts[3] + const week = parts[4] + if (day !== "*" || month !== "*") return + + const parsedDays = parseDays(week) + if (!parsedDays) return + + const time = parseTime(hour, minute) + if (!time) return + + if (days && days.join(",") !== parsedDays.join(",")) return + if (!days) days = parsedDays + + times.push(time) + } + + return { + days: days ?? dayOrder, + times: [...new Set(times)].sort(), + } +} + +function buildSchedule(days: string[], times: string[]) { + const normalizedDays = sortDays(days) + const normalizedTimes = [...new Set(times)].filter(Boolean) + + if (normalizedDays.length === 0) return "" + if (normalizedTimes.length === 0) return "" + + const dayList = normalizedDays.join(",") + const lines = normalizedTimes + .map((time) => { + const parts = time.split(":") + const hour = parts[0] + const minute = parts[1] + if (!hour || !minute) return undefined + + const h = String(Number(hour)).padStart(2, "0") + const m = String(Number(minute)).padStart(2, "0") + if (!parseTime(h, m)) return undefined + + return `${m} ${h} * * ${dayList}` + }) + .filter((line): line is string => !!line) + + return lines.join("\n") +} + +function projectLabel(project: Project) { + return project.name || getFilename(project.worktree) +} + +export function DialogAutomation(props: { automation?: Automation }) { + const dialog = useDialog() + const language = useLanguage() + const globalSDK = useGlobalSDK() + const globalSync = useGlobalSync() + const platform = usePlatform() + const command = useCommand() + + const initialSchedule = props.automation?.schedule ?? "" + const initialPrompt = props.automation?.prompt ?? "" + const parsed = parseBuilderSchedule(initialSchedule) + + const [store, setStore] = createStore({ + name: props.automation?.name ?? "", + prompt: initialPrompt, + parts: promptParts(initialPrompt), + enabled: props.automation?.enabled ?? true, + schedule: initialSchedule, + mode: parsed ? ("picker" as const) : initialSchedule ? ("cron" as const) : ("picker" as const), + days: parsed?.days ?? defaultDays, + times: parsed?.times ?? defaultTimes, + projects: props.automation?.projects ?? [], + saving: false, + }) + + const [preview, setPreview] = createStore({ + loading: false, + nextRun: undefined as number | undefined, + error: "", + }) + let previewTimeout: ReturnType | undefined + let previewId = 0 + + const title = () => { + if (props.automation) return language.t("automations.edit.title") + return language.t("automations.create.title") + } + + const projects = createMemo(() => { + return globalSync.data.project + .filter((project) => project.worktree && project.worktree !== "/") + .slice() + .sort((a, b) => projectLabel(a).localeCompare(projectLabel(b))) + }) + const home = createMemo(() => globalSync.data.path.home) + const dayOptions = createMemo(() => [ + { key: "1", label: language.t("automations.day.mon") }, + { key: "2", label: language.t("automations.day.tue") }, + { key: "3", label: language.t("automations.day.wed") }, + { key: "4", label: language.t("automations.day.thu") }, + { key: "5", label: language.t("automations.day.fri") }, + { key: "6", label: language.t("automations.day.sat") }, + { key: "0", label: language.t("automations.day.sun") }, + ]) + const presets = createMemo(() => [ + { + id: "weekdays", + label: language.t("automations.form.presets.weekdays"), + days: defaultDays, + times: defaultTimes, + }, + { + id: "daily", + label: language.t("automations.form.presets.daily"), + days: dayOrder, + times: defaultTimes, + }, + ]) + + const directory = createMemo(() => store.projects[0] ?? "") + const clients = new Map>() + const clientFor = (dir: string) => { + const cached = clients.get(dir) + if (cached) return cached + const next = createOpencodeClient({ + baseUrl: globalSDK.url, + fetch: platform.fetch, + directory: dir, + throwOnError: true, + }) + clients.set(dir, next) + return next + } + + const [customCommands] = createResource(directory, (dir) => { + if (!dir) return [] + return clientFor(dir) + .command.list() + .then((x) => x.data ?? []) + .catch(() => []) + }) + + const [agents] = createResource(directory, (dir) => { + if (!dir) return [] + return clientFor(dir) + .app.agents() + .then((x) => x.data ?? []) + .catch(() => []) + }) + + const searchFiles = async (query: string) => { + const dir = directory() + if (!dir) return [] + return clientFor(dir) + .find.files({ query, dirs: "true" }) + .then((x) => x.data ?? []) + .catch(() => []) + } + + const slashCommands = createMemo(() => { + const builtin = command.options + .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash) + .map((opt) => ({ + id: opt.id, + trigger: opt.slash!, + title: opt.title, + description: opt.description, + keybind: opt.keybind, + type: "builtin" as const, + })) + const custom = (customCommands() ?? []).map((cmd) => ({ + id: `custom.${cmd.name}`, + trigger: cmd.name, + title: cmd.name, + description: cmd.description, + type: "custom" as const, + source: cmd.source, + })) + return [...custom, ...builtin] + }) + + const templates = createMemo(() => [ + { + value: "{{date}}", + label: language.t("automations.template.date.label"), + description: language.t("automations.template.date.description"), + }, + { + value: "{{project.name}}", + label: language.t("automations.template.project.label"), + description: language.t("automations.template.project.description"), + }, + { + value: "{{session.latest}}", + label: language.t("automations.template.sessionLatest.label"), + description: language.t("automations.template.sessionLatest.description"), + }, + { + value: "{{session.query:term}}", + label: language.t("automations.template.sessionQuery.label"), + description: language.t("automations.template.sessionQuery.description"), + }, + ]) + + const scheduleValue = createMemo(() => { + if (store.mode === "cron") return store.schedule.trim() + return buildSchedule(store.days, store.times) + }) + const scheduleError = createMemo(() => { + if (!store.enabled) return "" + if (!scheduleValue()) return language.t("automations.form.schedule.invalid") + if (preview.loading) return "" + if (!preview.error) return "" + + return language.t("automations.form.schedule.invalidCron", { error: preview.error }) + }) + const schedulePreview = createMemo(() => { + if (!store.enabled) return "" + if (!scheduleValue()) return "" + if (preview.loading) return language.t("automations.form.schedule.calculating") + if (preview.error) return "" + + const next = preview.nextRun + const label = next + ? DateTime.fromMillis(next).toLocaleString(DateTime.DATETIME_SHORT) + : language.t("automations.time.never") + return `${language.t("automations.form.schedule.nextRun")}: ${label}` + }) + + const handlePromptChange = (value: Prompt) => { + setStore({ parts: value, prompt: promptText(value) }) + } + const scheduleReady = createMemo(() => { + if (!store.enabled) return true + + if (!scheduleValue()) return false + if (preview.loading) return false + if (preview.error) return false + + return true + }) + const canSave = createMemo(() => { + if (!store.name.trim()) return false + if (!store.prompt.trim()) return false + if (store.projects.length === 0) return false + if (!scheduleReady()) return false + + return true + }) + + createEffect( + on([() => store.enabled, scheduleValue], ([enabled, schedule]) => { + previewId += 1 + const current = previewId + if (previewTimeout) clearTimeout(previewTimeout) + if (!enabled || !schedule) { + setPreview({ loading: false, nextRun: undefined, error: "" }) + return + } + setPreview({ loading: true, nextRun: undefined, error: "" }) + previewTimeout = setTimeout(() => { + globalSDK.client.automation + .preview({ schedule }) + .then((result) => { + if (current !== previewId) return + const data = result.data + setPreview({ + loading: false, + nextRun: data?.nextRun, + error: data?.error ?? "", + }) + }) + .catch((error) => { + if (current !== previewId) return + const message = error instanceof Error ? error.message : String(error) + setPreview({ loading: false, nextRun: undefined, error: message }) + }) + }, 200) + }), + ) + + onCleanup(() => { + if (previewTimeout) clearTimeout(previewTimeout) + previewId += 1 + }) + + function updateSchedule(value: string) { + setStore("schedule", value) + } + + function toggleDay(day: string, checked: boolean) { + if (checked) { + setStore("days", (prev) => sortDays([...prev, day])) + return + } + + setStore("days", (prev) => prev.filter((item) => item !== day)) + } + + function updateTime(index: number, value: string) { + setStore("times", (prev) => prev.map((item, i) => (i === index ? value : item))) + } + + function addTime() { + setStore("times", (prev) => [...prev, "09:00"]) + } + + function applyPreset(preset: { days: string[]; times: string[] }) { + setStore({ mode: "picker", days: [...preset.days], times: [...preset.times] }) + } + + function removeTime(index: number) { + setStore("times", (prev) => prev.filter((_, i) => i !== index)) + } + + function toggleProject(directory: string, checked: boolean) { + if (checked) { + setStore("projects", (prev) => { + if (prev.includes(directory)) return prev + return [...prev, directory] + }) + return + } + + setStore("projects", (prev) => prev.filter((item) => item !== directory)) + } + + async function handleSave() { + if (!canSave()) return + setStore("saving", true) + + const schedule = scheduleValue() + const savedSchedule = schedule ? schedule : null + const payload = { + name: store.name.trim(), + projects: store.projects, + prompt: store.prompt, + schedule: savedSchedule, + enabled: store.enabled, + } + const request = props.automation + ? globalSDK.client.automation.update({ automationID: props.automation.id, ...payload }) + : globalSDK.client.automation.create(payload) + + await request.then(() => dialog.close()).finally(() => setStore("saving", false)) + } + + const deleteAutomation = async (automation: Automation) => { + await globalSDK.client.automation.remove({ automationID: automation.id }) + } + + const openDelete = () => { + const automation = props.automation + if (!automation) return + dialog.show(() => ) + } + + return ( + +
{ + e.preventDefault() + handleSave() + }} + class="flex flex-col h-full min-h-0" + > +
+
+
+ setStore("name", value)} + /> + +
+
+
+
+ {language.t("automations.form.projects")} + + {language.t("automations.form.projects.count", { count: store.projects.length })} + +
+
+
+ 0} + fallback={ +
+ {language.t("automations.form.projects.empty")} +
+ } + > + + {(project) => ( +
+ toggleProject(project.worktree, checked)} + description={project.worktree.replace(home(), "~")} + > + {projectLabel(project)} + +
+ )} +
+
+
+
+
+ +
+
+ {language.t("automations.form.prompt")} + + {language.t("automations.form.prompt.description")} + +
+ +
+ +
+
+
+ {language.t("automations.form.schedule")} + {language.t("automations.form.schedule.hint")} +
+ setStore("enabled", value)} hideLabel> + {language.t("automations.form.schedule.enabled")} + +
+ + +
+
+ {language.t("automations.form.presets")} +
+ + {(preset) => ( + + )} + +
+
+ + value === "picker" + ? language.t("automations.form.schedule.mode.picker") + : language.t("automations.form.schedule.mode.cron") + } + onSelect={(value) => { + if (!value) return + if (value === "cron") { + setStore({ mode: value, schedule: scheduleValue() }) + return + } + const parsedSchedule = parseBuilderSchedule(store.schedule) + const cron = store.schedule.trim() + if (cron && !parsedSchedule) return + setStore("mode", value) + if (parsedSchedule) { + setStore({ days: parsedSchedule.days, times: parsedSchedule.times }) + } + }} + /> + + +
+ +
+ + {(day) => ( + toggleDay(day.key, value)} + /> + )} + +
+
+ +
+
+ + +
+
+ 0} + fallback={ +
+ {language.t("automations.form.times.empty")} +
+ } + > +
+ + {(time, index) => ( + updateTime(index(), value)} + onRemove={() => removeTime(index())} + /> + )} + +
+
+
+
+ + +
+ + + + + + +
{schedulePreview()}
+
+ +
{scheduleError()}
+
+
+
+
+
+
+
+ +
+
+ }> + + +
+ + +
+
+
+
+
+ ) +} diff --git a/packages/app/src/components/dialog-confirm.tsx b/packages/app/src/components/dialog-confirm.tsx new file mode 100644 index 00000000000..5c1c90296a9 --- /dev/null +++ b/packages/app/src/components/dialog-confirm.tsx @@ -0,0 +1,65 @@ +import { Button, type ButtonProps } from "@opencode-ai/ui/button" +import { Dialog } from "@opencode-ai/ui/dialog" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Show, type JSX } from "solid-js" +import { createStore } from "solid-js/store" + +export function DialogConfirm(props: { + title: string + message?: JSX.Element + confirmLabel: string + cancelLabel: string + confirmVariant?: ButtonProps["variant"] + onConfirm: () => Promise | void + onCancel?: () => void +}) { + const dialog = useDialog() + const [store, setStore] = createStore({ + confirming: false, + }) + + const handleConfirm = async () => { + if (store.confirming) return + setStore("confirming", true) + + await Promise.resolve(props.onConfirm()) + .then(() => { + dialog.close() + }) + .finally(() => { + setStore("confirming", false) + }) + } + + const handleCancel = () => { + if (store.confirming) return + + props.onCancel?.() + dialog.close() + } + + return ( + +
+ +
+ {props.message} +
+
+
+ + +
+
+
+ ) +} diff --git a/packages/app/src/components/prompt-editor.tsx b/packages/app/src/components/prompt-editor.tsx new file mode 100644 index 00000000000..336b8b15ecd --- /dev/null +++ b/packages/app/src/components/prompt-editor.tsx @@ -0,0 +1,1002 @@ +import { createEffect, createMemo, createSignal, For, on, onCleanup, Show, Switch, Match } from "solid-js" +import { Portal } from "solid-js/web" +import { createStore } from "solid-js/store" +import { createFocusSignal } from "@solid-primitives/active-element" +import { useFilteredList } from "@opencode-ai/ui/hooks" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { Icon } from "@opencode-ai/ui/icon" +import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { useLanguage } from "@/context/language" +import { DEFAULT_PROMPT, type AgentPart, type FileAttachmentPart, type Prompt } from "@/context/prompt" + +export type SlashCommand = { + id: string + trigger: string + title: string + description?: string + keybind?: string + type: "builtin" | "custom" + source?: "command" | "mcp" | "skill" +} + +export type TemplateOption = { + value: string + label: string + description?: string +} + +type AtOption = + | { type: "agent"; name: string; display: string } + | { type: "file"; path: string; display: string; recent?: boolean } + +export function promptText(parts: Prompt) { + return parts.map((part) => ("content" in part ? part.content : "")).join("") +} + +export function PromptEditor(props: { + value: Prompt + placeholder: string + class?: string + editorClass?: string + showPlaceholder?: boolean + portal?: boolean + portalMount?: HTMLElement + mode?: "normal" | "shell" + onChange: (value: Prompt, cursor: number) => void + onKeyDown?: (event: KeyboardEvent, state: { popover: "at" | "slash" | "template" | null }) => boolean | void + onPaste?: (event: ClipboardEvent) => boolean | void | Promise + ref?: (el: HTMLDivElement) => void + scrollRef?: (el: HTMLDivElement) => void + slash: { + commands: SlashCommand[] + keybind?: (id: string) => string | undefined + onSelect?: (command: SlashCommand) => boolean | void + } + at: { + agents?: { name: string; hidden?: boolean; mode?: string }[] + recent?: string[] + search?: (query: string) => Promise + } + templates?: { + items: TemplateOption[] + onSelect?: (option: TemplateOption) => boolean | void + } +}) { + const language = useLanguage() + let editorRef!: HTMLDivElement + let scrollRef!: HTMLDivElement + let slashPopoverRef!: HTMLDivElement + const mirror = { input: false } + const mode = () => props.mode ?? "normal" + + const [store, setStore] = createStore<{ popover: "at" | "slash" | "template" | null }>({ popover: null }) + const [rect, setRect] = createStore({ left: 0, top: 0, width: 0 }) + const [composing, setComposing] = createSignal(false) + + const isFocused = createFocusSignal(() => editorRef) + + createEffect(() => { + if (isFocused()) return + setStore("popover", null) + }) + + createEffect(() => { + if (!props.portal) return + if (!store.popover) return + const update = () => { + const bounds = editorRef.getBoundingClientRect() + setRect({ left: bounds.left, top: bounds.top - 12, width: bounds.width }) + } + + update() + window.addEventListener("resize", update) + window.addEventListener("scroll", update, true) + onCleanup(() => { + window.removeEventListener("resize", update) + window.removeEventListener("scroll", update, true) + }) + }) + + // Safety: reset composing state on focus change to prevent stuck state + createEffect(() => { + if (isFocused()) return + setComposing(false) + }) + + const agentList = createMemo(() => + (props.at.agents ?? []) + .filter((agent) => !agent.hidden && agent.mode !== "primary") + .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), + ) + const recent = createMemo(() => props.at.recent ?? []) + const templateItems = createMemo(() => props.templates?.items ?? []) + const templateMatch = (value: string) => value.match(/\{\{([^\s}]*)$/) + + const handleAtSelect = (option: AtOption | undefined) => { + if (!option) return + if (option.type === "agent") { + addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 }) + return + } + addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) + } + + const atKey = (x: AtOption | undefined) => { + if (!x) return "" + return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}` + } + + const { + flat: atFlat, + active: atActive, + setActive: setAtActive, + onInput: atOnInput, + onKeyDown: atOnKeyDown, + } = useFilteredList({ + items: async (query) => { + const agents = agentList() + const open = recent() + const seen = new Set(open) + const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) + if (!props.at.search) return [...agents, ...pinned] + const paths = await props.at.search(query) + const fileOptions: AtOption[] = paths + .filter((path) => !seen.has(path)) + .map((path) => ({ type: "file", path, display: path })) + return [...agents, ...pinned, ...fileOptions] + }, + key: atKey, + filterKeys: ["display"], + groupBy: (item) => { + if (item.type === "agent") return "agent" + if (item.recent) return "recent" + return "file" + }, + sortGroupsBy: (a, b) => { + const rank = (category: string) => { + if (category === "agent") return 0 + if (category === "recent") return 1 + return 2 + } + return rank(a.category) - rank(b.category) + }, + onSelect: handleAtSelect, + }) + + const handleSlashSelect = (cmd: SlashCommand | undefined) => { + if (!cmd) return + setStore("popover", null) + if (props.slash.onSelect?.(cmd)) return + + const text = `/${cmd.trigger} ` + editorRef.innerHTML = "" + editorRef.textContent = text + mirror.input = true + props.onChange([{ type: "text", content: text, start: 0, end: text.length }], text.length) + requestAnimationFrame(() => { + editorRef.focus() + const range = document.createRange() + const sel = window.getSelection() + range.selectNodeContents(editorRef) + range.collapse(false) + sel?.removeAllRanges() + sel?.addRange(range) + }) + } + + const { + flat: slashFlat, + active: slashActive, + setActive: setSlashActive, + onInput: slashOnInput, + onKeyDown: slashOnKeyDown, + refetch: slashRefetch, + } = useFilteredList({ + items: () => props.slash.commands, + key: (x) => x?.id, + filterKeys: ["trigger", "title", "description"], + onSelect: handleSlashSelect, + }) + + createEffect( + on( + () => props.slash.commands, + () => slashRefetch(), + { defer: true }, + ), + ) + + // Auto-scroll active command into view when navigating with keyboard + createEffect(() => { + const activeId = slashActive() + if (!activeId || !slashPopoverRef) return + + requestAnimationFrame(() => { + const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`) + element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) + }) + }) + + const selectPopoverActive = () => { + if (store.popover === "at") { + const items = atFlat() + if (items.length === 0) return + const active = atActive() + const item = items.find((entry) => atKey(entry) === active) ?? items[0] + handleAtSelect(item) + return + } + + if (store.popover === "slash") { + const items = slashFlat() + if (items.length === 0) return + const active = slashActive() + const item = items.find((entry) => entry.id === active) ?? items[0] + handleSlashSelect(item) + } + + if (store.popover === "template") { + const items = templateFlat() + if (items.length === 0) return + const active = templateActive() + const item = items.find((entry) => entry.value === active) ?? items[0] + handleTemplateSelect(item) + } + } + + const isNormalizedEditor = () => + Array.from(editorRef.childNodes).every((node) => { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? "" + if (!text.includes("\u200B")) return true + if (text !== "\u200B") return false + + const prev = node.previousSibling + const next = node.nextSibling + const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR" + const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR" + if (!prevIsBr && !nextIsBr) return false + if (nextIsBr && !prevIsBr && prev) return false + return true + } + if (node.nodeType !== Node.ELEMENT_NODE) return false + const el = node as HTMLElement + if (el.dataset.type === "file") return true + if (el.dataset.type === "agent") return true + return el.tagName === "BR" + }) + + const renderEditor = (parts: Prompt) => { + editorRef.innerHTML = "" + for (const part of parts) { + if (part.type === "text") { + editorRef.appendChild(createTextFragment(part.content)) + continue + } + if (part.type === "file" || part.type === "agent") { + editorRef.appendChild(createPill(part)) + } + } + } + + createEffect( + on( + () => props.value, + (currentParts) => { + if (mirror.input) { + mirror.input = false + if (isNormalizedEditor()) return + + const selection = window.getSelection() + let cursorPosition: number | null = null + if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) { + cursorPosition = getCursorPosition(editorRef) + } + + renderEditor(currentParts) + + if (cursorPosition !== null) { + setCursorPosition(editorRef, cursorPosition) + } + return + } + + const domParts = parseFromDOM() + if (isNormalizedEditor() && isPromptEqual(currentParts, domParts)) return + + const selection = window.getSelection() + let cursorPosition: number | null = null + if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) { + cursorPosition = getCursorPosition(editorRef) + } + + renderEditor(currentParts) + + if (cursorPosition !== null) { + setCursorPosition(editorRef, cursorPosition) + } + }, + ), + ) + + const parseFromDOM = (): Prompt => { + const parts: Prompt = [] + let position = 0 + let buffer = "" + + const flushText = () => { + const content = buffer.replace(/\r\n?/g, "\n").replace(/\u200B/g, "") + buffer = "" + if (!content) return + parts.push({ type: "text", content, start: position, end: position + content.length }) + position += content.length + } + + const pushFile = (file: HTMLElement) => { + const content = file.textContent ?? "" + parts.push({ + type: "file", + path: file.dataset.path!, + content, + start: position, + end: position + content.length, + }) + position += content.length + } + + const pushAgent = (agent: HTMLElement) => { + const content = agent.textContent ?? "" + parts.push({ + type: "agent", + name: agent.dataset.name!, + content, + start: position, + end: position + content.length, + }) + position += content.length + } + + const visit = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + buffer += node.textContent ?? "" + return + } + if (node.nodeType !== Node.ELEMENT_NODE) return + + const el = node as HTMLElement + if (el.dataset.type === "file") { + flushText() + pushFile(el) + return + } + if (el.dataset.type === "agent") { + flushText() + pushAgent(el) + return + } + if (el.tagName === "BR") { + buffer += "\n" + return + } + + for (const child of Array.from(el.childNodes)) { + visit(child) + } + } + + const children = Array.from(editorRef.childNodes) + children.forEach((child, index) => { + const isBlock = child.nodeType === Node.ELEMENT_NODE && ["DIV", "P"].includes((child as HTMLElement).tagName) + visit(child) + if (isBlock && index < children.length - 1) { + buffer += "\n" + } + }) + + flushText() + + if (parts.length === 0) parts.push(...DEFAULT_PROMPT) + return parts + } + + const handleInput = () => { + const rawParts = parseFromDOM() + const cursorPosition = getCursorPosition(editorRef) + const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("") + const trimmed = rawText.replace(/\u200B/g, "").trim() + const hasNonText = rawParts.some((part) => part.type !== "text") + const shouldReset = trimmed.length === 0 && !hasNonText + + if (shouldReset) { + setStore("popover", null) + mirror.input = true + props.onChange(DEFAULT_PROMPT, 0) + return + } + + if (mode() === "shell") { + setStore("popover", null) + } + + if (mode() !== "shell") { + const textBeforeCursor = rawText.substring(0, cursorPosition) + const templateResult = templateItems().length > 0 ? templateMatch(textBeforeCursor) : null + const atMatch = textBeforeCursor.match(/@(\S*)$/) + const slashMatch = rawText.match(/^\/(\S*)$/) + + let popover: "template" | "at" | "slash" | null = null + if (templateResult) popover = "template" + if (!templateResult && atMatch) popover = "at" + if (!templateResult && !atMatch && slashMatch) popover = "slash" + + if (popover === "template" && templateResult) templateOnInput(templateResult[1]) + if (popover === "at" && atMatch) atOnInput(atMatch[1]) + if (popover === "slash" && slashMatch) slashOnInput(slashMatch[1]) + + setStore("popover", popover) + } + + mirror.input = true + props.onChange(rawParts, cursorPosition) + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && store.popover) { + setStore("popover", null) + event.preventDefault() + return + } + + const handled = props.onKeyDown?.(event, { popover: store.popover }) + if (handled) return + if (event.defaultPrevented) return + + const isEnter = event.key === "Enter" + const isShiftEnter = isEnter && event.shiftKey + if (isShiftEnter) { + addPart({ type: "text", content: "\n", start: 0, end: 0 }) + event.preventDefault() + return + } + + const isComposing = event.isComposing || composing() || event.keyCode === 229 + if (isEnter && isComposing) { + return + } + + const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey + + if (ctrl && event.code === "KeyG" && store.popover) { + setStore("popover", null) + event.preventDefault() + return + } + + const popover = store.popover + if (!popover) return + + if (event.key === "Tab") { + selectPopoverActive() + event.preventDefault() + return + } + + const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter" + const ctrlNav = ctrl && (event.key === "n" || event.key === "p") + if (!nav && !ctrlNav) return + + if (popover === "at") { + atOnKeyDown(event) + event.preventDefault() + return + } + if (popover === "slash") { + slashOnKeyDown(event) + } + if (popover === "template") { + templateOnKeyDown(event) + } + event.preventDefault() + } + + const handlePaste = async (event: ClipboardEvent) => { + const handled = await props.onPaste?.(event) + if (handled) return + if (event.defaultPrevented) return + + const clipboardData = event.clipboardData + if (!clipboardData) return + + const plainText = clipboardData.getData("text/plain") ?? "" + if (!plainText) return + + event.preventDefault() + event.stopPropagation() + addPart({ type: "text", content: plainText, start: 0, end: 0 }) + } + + const createPill = (part: FileAttachmentPart | AgentPart) => { + const pill = document.createElement("span") + pill.textContent = part.content + pill.setAttribute("data-type", part.type) + if (part.type === "file") pill.setAttribute("data-path", part.path) + if (part.type === "agent") pill.setAttribute("data-name", part.name) + pill.setAttribute("contenteditable", "false") + pill.style.userSelect = "text" + pill.style.cursor = "default" + return pill + } + + const setRangeEdge = (range: Range, edge: "start" | "end", offset: number) => { + let remaining = offset + const nodes = Array.from(editorRef.childNodes) + + for (const node of nodes) { + const length = getNodeLength(node) + const isText = node.nodeType === Node.TEXT_NODE + const isPill = + node.nodeType === Node.ELEMENT_NODE && + ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent") + const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" + + if (isText && remaining <= length) { + if (edge === "start") range.setStart(node, remaining) + if (edge === "end") range.setEnd(node, remaining) + return + } + + if ((isPill || isBreak) && remaining <= length) { + if (edge === "start" && remaining === 0) range.setStartBefore(node) + if (edge === "start" && remaining > 0) range.setStartAfter(node) + if (edge === "end" && remaining === 0) range.setEndBefore(node) + if (edge === "end" && remaining > 0) range.setEndAfter(node) + return + } + + remaining -= length + } + } + + const insertText = (content: string, replace?: { start: number; end: number }) => { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return + + const range = selection.getRangeAt(0) + if (replace) { + setRangeEdge(range, "start", replace.start) + setRangeEdge(range, "end", replace.end) + range.deleteContents() + } + + const fragment = createTextFragment(content) + const last = fragment.lastChild + range.insertNode(fragment) + if (last) { + if (last.nodeType === Node.TEXT_NODE) { + const text = last.textContent ?? "" + if (text === "\u200B") { + range.setStart(last, 0) + } + if (text !== "\u200B") { + range.setStart(last, text.length) + } + } + if (last.nodeType !== Node.TEXT_NODE) { + range.setStartAfter(last) + } + } + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + + const handleTemplateSelect = (option: TemplateOption | undefined) => { + if (!option) return + setStore("popover", null) + if (props.templates?.onSelect?.(option)) return + + const cursorPosition = getCursorPosition(editorRef) + const currentPrompt = parseFromDOM() + const rawText = promptText(currentPrompt) + const textBeforeCursor = rawText.substring(0, cursorPosition) + const match = templateMatch(textBeforeCursor) + + let replace: { start: number; end: number } | undefined + if (match) { + const start = match.index ?? cursorPosition - match[0].length + const end = cursorPosition + replace = { start, end } + } + + insertText(option.value, replace) + handleInput() + } + + const { + flat: templateFlat, + active: templateActive, + setActive: setTemplateActive, + onInput: templateOnInput, + onKeyDown: templateOnKeyDown, + } = useFilteredList({ + items: () => templateItems(), + key: (x) => x?.value, + filterKeys: ["label", "description", "value"], + onSelect: handleTemplateSelect, + }) + + const addPart = (part: Prompt[number]) => { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return + + const cursorPosition = getCursorPosition(editorRef) + const currentPrompt = parseFromDOM() + const rawText = promptText(currentPrompt) + const textBeforeCursor = rawText.substring(0, cursorPosition) + const atMatch = textBeforeCursor.match(/@(\S*)$/) + + if (part.type === "file" || part.type === "agent") { + const pill = createPill(part) + const gap = document.createTextNode(" ") + const range = selection.getRangeAt(0) + + if (atMatch) { + const start = atMatch.index ?? cursorPosition - atMatch[0].length + setRangeEdge(range, "start", start) + setRangeEdge(range, "end", cursorPosition) + } + + range.deleteContents() + range.insertNode(gap) + range.insertNode(pill) + range.setStartAfter(gap) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + + if (part.type === "text") { + const range = selection.getRangeAt(0) + const fragment = createTextFragment(part.content) + const last = fragment.lastChild + + range.deleteContents() + range.insertNode(fragment) + if (last) { + if (last.nodeType === Node.TEXT_NODE) { + const text = last.textContent ?? "" + if (text === "\u200B") { + range.setStart(last, 0) + } + if (text !== "\u200B") { + range.setStart(last, text.length) + } + } + if (last.nodeType !== Node.TEXT_NODE) { + range.setStartAfter(last) + } + } + + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + + handleInput() + } + + const popover = () => ( +
{ + if (store.popover === "slash") slashPopoverRef = el + }} + classList={{ + "origin-bottom-left max-h-80 min-h-10 overflow-auto no-scrollbar flex flex-col p-2 rounded-md z-50": true, + "border border-border-base bg-surface-raised-stronger-non-alpha shadow-md": true, + "absolute inset-x-0 -top-3 -translate-y-full": !props.portal, + fixed: !!props.portal, + }} + style={ + props.portal + ? { + left: `${rect.left}px`, + top: `${rect.top}px`, + width: `${rect.width}px`, + transform: "translateY(-100%)", + } + : undefined + } + onMouseDown={(e) => e.preventDefault()} + > + + + 0} + fallback={
{language.t("prompt.popover.emptyResults")}
} + > + + {(item) => ( + + )} + +
+
+ + 0} + fallback={
{language.t("prompt.popover.emptyCommands")}
} + > + + {(cmd) => ( + + )} + +
+
+ + 0} + fallback={
{language.t("prompt.popover.emptyResults")}
} + > + + {(item) => ( + + )} + +
+
+
+
+ ) + + return ( +
+ + + + {popover()} + + {popover()} + + +
{ + scrollRef = el + props.scrollRef?.(el) + }} + > +
{ + editorRef = el + props.ref?.(el) + }} + role="textbox" + aria-multiline="true" + aria-label={props.placeholder} + contenteditable="true" + onInput={handleInput} + onPaste={handlePaste} + onCompositionStart={() => setComposing(true)} + onCompositionEnd={() => setComposing(false)} + onKeyDown={handleKeyDown} + classList={{ + "select-text": true, + "w-full p-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, + "[&_[data-type=file]]:text-syntax-property": true, + "[&_[data-type=agent]]:text-syntax-type": true, + "font-mono!": mode() === "shell", + [props.editorClass ?? ""]: !!props.editorClass, + }} + /> + +
+ {props.placeholder} +
+
+
+
+ ) +} + +function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { + if (promptA.length !== promptB.length) return false + for (let i = 0; i < promptA.length; i++) { + const partA = promptA[i] + const partB = promptB[i] + if (partA.type !== partB.type) return false + if (partA.type === "text" && partA.content !== (partB as { content: string }).content) return false + if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) return false + if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) return false + } + return true +} + +export function createTextFragment(content: string): DocumentFragment { + const fragment = document.createDocumentFragment() + const segments = content.split("\n") + segments.forEach((segment, index) => { + if (segment) { + fragment.appendChild(document.createTextNode(segment)) + } + if (!segment && segments.length > 1) { + fragment.appendChild(document.createTextNode("\u200B")) + } + if (index < segments.length - 1) { + fragment.appendChild(document.createElement("br")) + } + }) + return fragment +} + +function getNodeLength(node: Node): number { + if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 + return (node.textContent ?? "").replace(/\u200B/g, "").length +} + +function getTextLength(node: Node): number { + if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length + if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 + let length = 0 + for (const child of Array.from(node.childNodes)) { + length += getTextLength(child) + } + return length +} + +export function getCursorPosition(parent: HTMLElement): number { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return 0 + const range = selection.getRangeAt(0) + if (!parent.contains(range.startContainer)) return 0 + const preCaretRange = range.cloneRange() + preCaretRange.selectNodeContents(parent) + preCaretRange.setEnd(range.startContainer, range.startOffset) + return getTextLength(preCaretRange.cloneContents()) +} + +export function setCursorPosition(parent: HTMLElement, position: number) { + let remaining = position + let node = parent.firstChild + while (node) { + const length = getNodeLength(node) + const isText = node.nodeType === Node.TEXT_NODE + const isPill = + node.nodeType === Node.ELEMENT_NODE && + ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent") + const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" + + if (isText && remaining <= length) { + const range = document.createRange() + const selection = window.getSelection() + range.setStart(node, remaining) + range.collapse(true) + selection?.removeAllRanges() + selection?.addRange(range) + return + } + + if ((isPill || isBreak) && remaining <= length) { + const range = document.createRange() + const selection = window.getSelection() + if (remaining === 0) { + range.setStartBefore(node) + } + if (remaining > 0 && isPill) { + range.setStartAfter(node) + } + if (remaining > 0 && isBreak) { + const next = node.nextSibling + if (next && next.nodeType === Node.TEXT_NODE) { + range.setStart(next, 0) + } + if (!next || next.nodeType !== Node.TEXT_NODE) { + range.setStartAfter(node) + } + } + range.collapse(true) + selection?.removeAllRanges() + selection?.addRange(range) + return + } + + remaining -= length + node = node.nextSibling + } + + const fallbackRange = document.createRange() + const fallbackSelection = window.getSelection() + const last = parent.lastChild + if (last && last.nodeType === Node.TEXT_NODE) { + const len = last.textContent ? last.textContent.length : 0 + fallbackRange.setStart(last, len) + } + if (!last || last.nodeType !== Node.TEXT_NODE) { + fallbackRange.selectNodeContents(parent) + } + fallbackRange.collapse(false) + fallbackSelection?.removeAllRanges() + fallbackSelection?.addRange(fallbackRange) +} diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index e2bf4498074..01288c4e57b 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -1,4 +1,5 @@ import { + type Automation, type Config, type Path, type Project, @@ -33,6 +34,7 @@ import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap" import { sanitizeProject } from "./global-sync/utils" +import { Binary } from "@opencode-ai/util/binary" import type { ProjectMeta } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" @@ -41,6 +43,7 @@ type GlobalStore = { error?: InitError path: Path project: Project[] + automation: Automation[] provider: ProviderListResponse provider_auth: ProviderAuthResponse config: Config @@ -73,6 +76,7 @@ function createGlobalSync() { ready: false, path: { state: "", config: "", worktree: "", directory: "", home: "" }, project: projectCache.value, + automation: [], provider: { all: [], connected: [], default: {} }, provider_auth: {}, config: {}, @@ -247,11 +251,20 @@ function createGlobalSync() { const unsub = globalSDK.event.listen((e) => { const directory = e.name const event = e.details + if (!event) return if (directory === "global") { applyGlobalEvent({ event, project: globalStore.project, + automations: globalStore.automation, + setAutomations(next) { + if (typeof next === "function") { + setGlobalStore("automation", produce(next)) + return + } + setGlobalStore("automation", next) + }, refresh: queue.refresh, setGlobalProject(next) { if (typeof next === "function") { diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 2137a19a823..392da043bc2 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -14,12 +14,14 @@ import { retry } from "@opencode-ai/util/retry" import { getFilename } from "@opencode-ai/util/path" import { showToast } from "@opencode-ai/ui/toast" import { cmp, normalizeProviderList } from "./utils" +import type { Automation } from "@opencode-ai/sdk/v2/client" import type { State, VcsCache } from "./types" type GlobalStore = { ready: boolean path: Path project: Project[] + automation: Automation[] provider: ProviderListResponse provider_auth: ProviderAuthResponse config: Config @@ -68,6 +70,14 @@ export async function bootstrapGlobal(input: { input.setGlobalStore("project", projects) }), ), + retry(() => + input.globalSDK.automation.list().then((x) => { + input.setGlobalStore( + "automation", + (x.data ?? []).slice().sort((a, b) => cmp(a.id, b.id)), + ) + }), + ), retry(() => input.globalSDK.provider.list().then((x) => { input.setGlobalStore("provider", normalizeProviderList(x.data!)) diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index c658d82c8b7..da77224b749 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -1,6 +1,6 @@ -import { Binary } from "@opencode-ai/util/binary" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { + Automation, FileDiff, Message, Part, @@ -11,12 +11,15 @@ import type { SessionStatus, Todo, } from "@opencode-ai/sdk/v2/client" +import { Binary } from "@opencode-ai/util/binary" import type { State, VcsCache } from "./types" import { trimSessions } from "./session-trim" export function applyGlobalEvent(input: { event: { type: string; properties?: unknown } project: Project[] + automations?: Automation[] + setAutomations?: (next: Automation[] | ((draft: Automation[]) => void)) => void setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void refresh: () => void }) { @@ -25,6 +28,57 @@ export function applyGlobalEvent(input: { return } + if (input.event.type === "automation.created" || input.event.type === "automation.updated") { + if (!input.automations || !input.setAutomations) return + const properties = input.event.properties as Automation + const result = Binary.search(input.automations, properties.id, (s) => s.id) + if (result.found) { + input.setAutomations((draft) => { + draft[result.index] = { ...draft[result.index], ...properties } + }) + return + } + input.setAutomations((draft) => { + draft.splice(result.index, 0, properties) + }) + return + } + + if (input.event.type === "automation.deleted") { + if (!input.automations || !input.setAutomations) return + const properties = input.event.properties as Automation + const result = Binary.search(input.automations, properties.id, (s) => s.id) + if (!result.found) return + input.setAutomations((draft) => { + draft.splice(result.index, 1) + }) + return + } + + if (input.event.type === "automation.run") { + if (!input.automations || !input.setAutomations) return + const properties = input.event.properties as { + automationID: string + time: number + status: "success" | "failed" + sessionID?: string + directory: string + } + const result = Binary.search(input.automations, properties.automationID, (s) => s.id) + if (!result.found) return + input.setAutomations((draft) => { + draft[result.index] = { + ...draft[result.index], + lastRun: properties.time, + lastSession: + properties.status === "success" && properties.sessionID + ? { id: properties.sessionID, directory: properties.directory } + : draft[result.index]?.lastSession, + } + }) + return + } + if (input.event.type !== "project.updated") return const properties = input.event.properties as Project const result = Binary.search(input.project, properties.id, (s) => s.id) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 8fba6861b0b..1d18959cf38 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -26,6 +26,7 @@ export const dict = { "command.provider.connect": "Connect provider", "command.server.switch": "Switch server", "command.settings.open": "Open settings", + "command.automations.open": "Open automations", "command.session.previous": "Previous session", "command.session.next": "Next session", "command.session.previous.unseen": "Previous unread session", @@ -568,6 +569,7 @@ export const dict = { "sidebar.menu.toggle": "Toggle menu", "sidebar.nav.projectsAndSessions": "Projects and sessions", "sidebar.settings": "Settings", + "sidebar.automations": "Automations", "sidebar.help": "Help", "sidebar.workspaces.enable": "Enable workspaces", "sidebar.workspaces.disable": "Disable workspaces", @@ -577,6 +579,98 @@ export const dict = { "sidebar.project.recentSessions": "Recent sessions", "sidebar.project.viewAllSessions": "View all sessions", + "session.automation.badge": "Automated run", + "session.automation.prefix": "Automation:", + + "automations.title": "Automations", + "automations.description": "Schedule or manually run prompts across projects.", + "automations.create.button": "Create automation", + "automations.edit.title": "Edit automation", + "automations.create.title": "New automation", + "automations.empty.title": "No automations yet", + "automations.empty.description": "Create one to run scheduled prompts across projects.", + "automations.table.name": "Name", + "automations.table.schedule": "Schedule", + "automations.table.next": "Next run", + "automations.table.last": "Last run", + "automations.badge.disabled": "Disabled", + "automations.action.run": "Run", + "automations.run.started": "Automation run started", + "automations.action.edit": "Edit", + "automations.action.delete": "Delete", + "automations.action.export": "Export", + "automations.action.openSession": "Open session", + "automations.action.history": "History", + "automations.export.all": "Export all", + "automations.export.empty": "No automations to export", + "automations.import.action": "Import", + "automations.import.success": "Imported {count} automations", + "automations.import.failed": "Failed to import automations", + "automations.delete.title": "Delete automation", + "automations.delete.confirm": "Delete {{name}}?", + "automations.delete.button": "Delete automation", + "automations.time.never": "Never", + "automations.time.unknown": "Unknown", + "automations.schedule.manual": "Manual only", + "automations.schedule.disabled": "Disabled - {{schedule}}", + "automations.schedule.multi": "Multiple - {{count}}", + "automations.history.title": "Run history", + "automations.history.empty": "No runs yet.", + "automations.history.table.time": "Time", + "automations.history.table.project": "Project", + "automations.history.table.status": "Status", + "automations.history.table.session": "Session", + "automations.history.status.success": "Success", + "automations.history.status.failed": "Failed", + "automations.history.clear.action": "Clear history", + "automations.history.clear.title": "Clear run history", + "automations.history.clear.confirm": "Clear run history for all automations?", + "automations.history.clear.button": "Clear history", + "automations.template.date.label": "Date", + "automations.template.date.description": "Current date", + "automations.template.project.label": "Project name", + "automations.template.project.description": "Automation project name", + "automations.template.sessionLatest.label": "Latest session", + "automations.template.sessionLatest.description": "Latest session ID for this project", + "automations.template.sessionQuery.label": "Session by query", + "automations.template.sessionQuery.description": "Latest session that matches the query", + + "automations.form.name": "Name", + "automations.form.name.placeholder": "Daily summary", + "automations.form.projects": "Projects", + "automations.form.projects.count": "{{count}} selected", + "automations.form.projects.empty": "No projects available.", + "automations.form.prompt": "Prompt", + "automations.form.prompt.description": "Supports slash commands, skills, @file references, and {{ template }} variables.", + "automations.form.prompt.placeholder": "Summarize today's progress and open PRs.", + "automations.form.schedule": "Schedule", + "automations.form.schedule.hint": "Cron uses local server time.", + "automations.form.schedule.enabled": "Enable schedule", + "automations.form.schedule.mode": "Schedule type", + "automations.form.schedule.mode.picker": "Picker", + "automations.form.schedule.mode.cron": "Cron", + "automations.form.schedule.invalid": "Add at least one day and one time to enable scheduling.", + "automations.form.schedule.invalidCron": "Invalid cron: {{error}}", + "automations.form.schedule.calculating": "Calculating next run...", + "automations.form.schedule.nextRun": "Next run", + "automations.form.presets": "Presets", + "automations.form.presets.weekdays": "Weekdays 9:00", + "automations.form.presets.daily": "Daily 9:00", + "automations.form.days": "Days", + "automations.form.times": "Times", + "automations.form.times.add": "Add time", + "automations.form.times.empty": "No times added yet.", + "automations.form.times.remove": "Remove time", + "automations.form.cron.preview": "Generated cron", + "automations.form.cron.expression": "Cron expression", + "automations.day.mon": "Mon", + "automations.day.tue": "Tue", + "automations.day.wed": "Wed", + "automations.day.thu": "Thu", + "automations.day.fri": "Fri", + "automations.day.sat": "Sat", + "automations.day.sun": "Sun", + "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "Desktop", diff --git a/packages/app/src/pages/automations.tsx b/packages/app/src/pages/automations.tsx new file mode 100644 index 00000000000..c0b3e8dfe60 --- /dev/null +++ b/packages/app/src/pages/automations.tsx @@ -0,0 +1,445 @@ +import { Button } from "@opencode-ai/ui/button" +import { Dialog } from "@opencode-ai/ui/dialog" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { DateTime } from "luxon" +import { createMemo, For, onMount, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { showToast } from "@opencode-ai/ui/toast" +import { useGlobalSDK } from "@/context/global-sdk" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" +import { DialogAutomation } from "@/components/dialog-automation" +import { DialogAutomationDelete } from "@/components/dialog-automation-delete" +import { DialogConfirm } from "@/components/dialog-confirm" +import { AutomationTransfer } from "@opencode-ai/util/automation-transfer" +import { base64Encode } from "@opencode-ai/util/encode" +import { getFilename } from "@opencode-ai/util/path" +import { slugify } from "@opencode-ai/util/slugify" +import { useNavigate } from "@solidjs/router" +import type { Automation, AutomationRun } from "@opencode-ai/sdk/v2/client" + +export default function AutomationsPage() { + const dialog = useDialog() + const globalSDK = useGlobalSDK() + const globalSync = useGlobalSync() + const language = useLanguage() + const navigate = useNavigate() + let importRef!: HTMLInputElement + + const automations = createMemo(() => + (globalSync.data.automation ?? []) + .filter((item): item is Automation => !!item && typeof item === "object") + .slice() + .sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? ""))), + ) + + const formatTime = (value?: number) => { + if (!value) return language.t("automations.time.never") + + const time = DateTime.fromMillis(value) + return time.toRelative() ?? time.toLocaleString(DateTime.DATETIME_SHORT) + } + + const projectLabel = (directory: string) => { + const project = globalSync.data.project.find((item) => item.worktree === directory) + if (project?.name) return project.name + + return getFilename(directory) + } + + const openSession = (session?: { id: string; directory: string }) => { + if (!session) return + navigate(`/${base64Encode(session.directory)}/session/${session.id}`) + } + + const projectList = (automation: Automation) => { + const projects = automation.projects ?? [] + const names = projects + .filter((directory) => directory && directory !== "/") + .map((directory) => projectLabel(directory)) + return names.join(", ") + } + + const scheduleLabel = (automation: Automation) => { + if (!automation.schedule) return language.t("automations.schedule.manual") + + const lines = automation.schedule + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + + if (!automation.enabled) { + const schedule = lines[0] ?? automation.schedule + return language.t("automations.schedule.disabled", { schedule }) + } + if (lines.length > 1) return language.t("automations.schedule.multi", { count: lines.length }) + return lines[0] ?? automation.schedule + } + + const openCreate = () => { + dialog.show(() => ) + } + + const openEdit = (automation: Automation) => { + dialog.show(() => ) + } + + const runAutomation = async (automation: Automation) => { + const result = await globalSDK.client.automation.run({ automationID: automation.id }).catch(() => undefined) + if (!result?.data) { + showToast({ title: language.t("common.requestFailed") }) + return + } + + const index = (globalSync.data.automation ?? []).findIndex((item) => item.id === result.data?.id) + if (index >= 0) globalSync.set("automation", index, result.data) + + showToast({ title: language.t("automations.run.started") }) + } + + const deleteAutomation = async (automation: Automation) => { + await globalSDK.client.automation.remove({ automationID: automation.id }) + } + + const clearHistory = async () => { + await globalSDK.client.automation.clearHistory() + } + + const downloadExport = (items: Automation[], filename: string) => { + if (items.length === 0) { + showToast({ title: language.t("automations.export.empty") }) + return + } + + const payload = AutomationTransfer.serialize( + items.map((item) => ({ + name: item.name, + projects: item.projects, + prompt: item.prompt, + schedule: item.schedule ?? null, + enabled: item.enabled, + })), + ) + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = filename + link.click() + link.remove() + URL.revokeObjectURL(url) + } + + const exportAll = () => { + downloadExport(automations(), "automations.json") + } + + const exportAutomation = (automation: Automation) => { + const name = slugify(String(automation.name ?? "")) + const suffix = name || automation.id.slice(-8) + downloadExport([automation], `automation-${suffix}.json`) + } + + const handleImport = async (event: Event & { currentTarget: HTMLInputElement }) => { + const input = event.currentTarget + const file = input.files?.[0] + input.value = "" + if (!file) return + + const data = await file + .text() + .then((text) => JSON.parse(text)) + .catch(() => undefined) + if (!data) { + showToast({ title: language.t("automations.import.failed") }) + return + } + + const items = AutomationTransfer.parse(data) + if (items.length === 0) { + showToast({ title: language.t("automations.import.failed") }) + return + } + + const results = await Promise.all( + items.map((item) => + globalSDK.client.automation + .create({ + name: item.name, + prompt: item.prompt, + projects: item.projects, + schedule: item.schedule ?? null, + enabled: item.enabled, + }) + .then(() => true) + .catch(() => false), + ), + ) + + const success = results.filter(Boolean).length + if (success === 0) { + showToast({ title: language.t("automations.import.failed") }) + return + } + const list = await globalSDK.client.automation.list().catch(() => undefined) + if (list?.data) { + globalSync.set( + "automation", + list.data.slice().sort((a, b) => String(a.id).localeCompare(String(b.id))), + ) + } + showToast({ title: language.t("automations.import.success", { count: success }) }) + } + + const openClearHistory = () => { + dialog.show(() => ( + + )) + } + + function DialogAutomationHistory(props: { automation: Automation }) { + const [state, setState] = createStore({ + loading: true, + error: "", + runs: [] as AutomationRun[], + }) + const runs = createMemo(() => (Array.isArray(state.runs) ? state.runs : [])) + + onMount(() => { + globalSDK.client.automation + .history({ automationID: props.automation.id, limit: 25 }) + .then((result) => { + const data = Array.isArray(result.data) ? result.data : [] + setState({ loading: false, error: "", runs: data }) + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error) + setState({ loading: false, error: message, runs: [] }) + }) + }) + + const formatStatus = (run: AutomationRun) => + run.status === "success" + ? language.t("automations.history.status.success") + : language.t("automations.history.status.failed") + + const formatRunTime = (value?: number) => { + if (!value || !Number.isFinite(value)) return language.t("automations.time.unknown") + + const time = DateTime.fromMillis(value) + return time.toLocaleString(DateTime.DATETIME_SHORT) + } + + return ( + +
+ + {state.error}
}> + 0} + fallback={
{language.t("automations.history.empty")}
} + > +
+ + + + + + + + + + + + + + + + + + + {(run) => ( + + + + + + + + )} + + +
{language.t("automations.history.table.time")}{language.t("automations.history.table.project")}{language.t("automations.history.table.status")}{language.t("automations.history.table.session")}
{formatRunTime(run.time)} +
{projectLabel(run.directory)}
+
{formatStatus(run)} +
{run.sessionID ?? "-"}
+
+
+ + + + openSession( + run.sessionID ? { id: run.sessionID, directory: run.directory } : undefined, + ) + } + /> + + +
+
+
+
+ + +
+ + ) + } + + const openDelete = (automation: Automation) => { + dialog.show(() => ) + } + + const openHistory = (automation: Automation) => { + dialog.show(() => ) + } + + return ( +
+
+
+

{language.t("automations.title")}

+

{language.t("automations.description")}

+
+
+ (importRef = el)} + type="file" + accept="application/json" + class="hidden" + onChange={handleImport} + /> + + + + +
+
+ + 0} + fallback={ +
+ +
{language.t("automations.empty.title")}
+
{language.t("automations.empty.description")}
+ +
+ } + > +
+ + + + + + + + + + + + + + + + + + + {(automation) => ( + + + + + + + + )} + + +
{language.t("automations.table.name")}{language.t("automations.table.schedule")}{language.t("automations.table.next")}{language.t("automations.table.last")}
+
+
+ {automation.name} + + + {language.t("automations.badge.disabled")} + + +
+
{projectList(automation)}
+
+
+
{scheduleLabel(automation)}
+
{formatTime(automation.nextRun)}{formatTime(automation.lastRun)} +
+ + + openSession(automation.lastSession)} + /> + + + + openHistory(automation)} /> + + + openEdit(automation)} /> + + + exportAutomation(automation)} /> + + + openDelete(automation)} /> + + + runAutomation(automation)} + /> + +
+
+
+
+
+ ) +} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 59adef4694a..39a29b58a01 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -936,6 +936,12 @@ export default function Layout(props: ParentProps) { keybind: "mod+comma", onSelect: () => openSettings(), }, + { + id: "automations.open", + title: language.t("command.automations.open"), + category: language.t("command.category.settings"), + onSelect: () => openAutomations(), + }, { id: "session.previous", title: language.t("command.session.previous"), @@ -1084,6 +1090,11 @@ export default function Layout(props: ParentProps) { dialog.show(() => ) } + function openAutomations() { + navigate("/automations") + layout.mobileSidebar.hide() + } + function navigateToProject(directory: string | undefined) { if (!directory) return if (!layout.sidebar.opened()) { @@ -1918,6 +1929,9 @@ export default function Layout(props: ParentProps) { renderProjectOverlay={() => ( layout.projects.list()} activeProject={() => store.activeProject} /> )} + automationsLabel={() => language.t("sidebar.automations")} + automationsKeybind={() => command.keybind("automations.open")} + onOpenAutomations={openAutomations} settingsLabel={() => language.t("sidebar.settings")} settingsKeybind={() => command.keybind("settings.open")} onOpenSettings={openSettings} @@ -1981,6 +1995,9 @@ export default function Layout(props: ParentProps) { renderProjectOverlay={() => ( layout.projects.list()} activeProject={() => store.activeProject} /> )} + automationsLabel={() => language.t("sidebar.automations")} + automationsKeybind={() => command.keybind("automations.open")} + onOpenAutomations={openAutomations} settingsLabel={() => language.t("sidebar.settings")} settingsKeybind={() => command.keybind("settings.open")} onOpenSettings={openSettings} diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx index ce96a09d114..7c5441ce98b 100644 --- a/packages/app/src/pages/layout/sidebar-shell.tsx +++ b/packages/app/src/pages/layout/sidebar-shell.tsx @@ -26,6 +26,9 @@ export const SidebarContent = (props: { openProjectKeybind: Accessor onOpenProject: () => void renderProjectOverlay: () => JSX.Element + automationsLabel?: Accessor + automationsKeybind?: Accessor + onOpenAutomations?: () => void settingsLabel: Accessor settingsKeybind: Accessor onOpenSettings: () => void @@ -78,6 +81,21 @@ export const SidebarContent = (props: {
+ + + props.onOpenAutomations?.()} + aria-label={props.automationsLabel?.()} + /> + + input.navigate(`/${input.params.dir}/session`), }, + { + id: "automations.open", + title: input.language.t("command.automations.open"), + category: input.language.t("command.category.settings"), + onSelect: () => input.navigate("/automations"), + }, ]) const fileCommands = createMemo(() => [ diff --git a/packages/opencode/package.json b/packages/opencode/package.json index a2024a7f7c1..0802bef887d 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -98,6 +98,7 @@ "bun-pty": "0.4.8", "chokidar": "4.0.3", "clipboardy": "4.0.0", + "cron-parser": "4.9.0", "decimal.js": "10.5.0", "diff": "catalog:", "fuzzysort": "3.1.0", diff --git a/packages/opencode/src/automation/index.ts b/packages/opencode/src/automation/index.ts new file mode 100644 index 00000000000..d68ba471076 --- /dev/null +++ b/packages/opencode/src/automation/index.ts @@ -0,0 +1,527 @@ +import { parseExpression } from "cron-parser" +import z from "zod" +import { BusEvent } from "../bus/bus-event" +import { GlobalBus } from "../bus/global" +import { Identifier } from "../id/id" +import { Instance } from "../project/instance" +import { InstanceBootstrap } from "../project/bootstrap" +import { Scheduler } from "../scheduler" +import { Session } from "../session" +import { SessionPrompt } from "../session/prompt" +import { Storage } from "../storage/storage" +import { Log } from "../util/log" +import { fn } from "../util/fn" +import { getFilename } from "@opencode-ai/util/path" +import { Config } from "../config/config" + +export namespace Automation { + const log = Log.create({ service: "automation" }) + const running = new Set() + const interval = 60 * 1000 + + export const Info = z + .object({ + id: Identifier.schema("automation"), + name: z.string(), + projects: z.array(z.string()), + prompt: z.string(), + schedule: z.string().nullable(), + enabled: z.boolean(), + time: z.object({ + created: z.number(), + updated: z.number(), + }), + lastRun: z.number().optional(), + nextRun: z.number().optional(), + lastSession: z + .object({ + id: Identifier.schema("session"), + directory: z.string(), + }) + .optional(), + createdBy: z.string().optional(), + updatedBy: z.string().optional(), + }) + .meta({ + ref: "Automation", + }) + export type Info = z.output + + export const Preview = z + .object({ + valid: z.boolean(), + nextRun: z.number().optional(), + error: z.string().optional(), + }) + .meta({ + ref: "AutomationPreview", + }) + export type Preview = z.output + + export const Run = z + .object({ + id: Identifier.schema("automation_run"), + automationID: Identifier.schema("automation"), + directory: z.string(), + sessionID: Identifier.schema("session").optional(), + status: z.enum(["success", "failed"]), + error: z.string().optional(), + time: z.number(), + }) + .meta({ + ref: "AutomationRun", + }) + export type Run = z.output + + export const HistoryClear = z + .object({ + cleared: z.number(), + }) + .meta({ + ref: "AutomationHistoryClear", + }) + export type HistoryClear = z.output + + export const Event = { + Created: BusEvent.define("automation.created", Info), + Updated: BusEvent.define("automation.updated", Info), + Deleted: BusEvent.define("automation.deleted", Info), + Run: BusEvent.define("automation.run", Run), + } + + const ScheduleInput = z + .string() + .optional() + .nullable() + .superRefine((value, ctx) => { + const schedule = normalizeSchedule(value) + if (!schedule) return + const preview = previewSchedule(schedule, Date.now()) + if (preview.valid) return + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: preview.error ?? "Invalid schedule", + }) + }) + + const NonEmptyInput = z.string().refine((value) => value.trim().length > 0, "Required") + + const CreateInput = z.object({ + name: NonEmptyInput, + projects: z + .array(z.string()) + .min(1) + .transform((input) => [...new Set(input.map((item) => item.trim()).filter(Boolean))]) + .refine((input) => input.length > 0, "At least one project is required"), + prompt: NonEmptyInput, + schedule: ScheduleInput, + enabled: z.boolean().optional(), + }) + + const UpdateInput = z.object({ + id: Identifier.schema("automation"), + name: NonEmptyInput.optional(), + projects: z + .array(z.string()) + .min(1) + .transform((input) => [...new Set(input.map((item) => item.trim()).filter(Boolean))]) + .refine((input) => input.length > 0, "At least one project is required") + .optional(), + prompt: NonEmptyInput.optional(), + schedule: ScheduleInput, + enabled: z.boolean().optional(), + }) + + export const create = fn(CreateInput, async (input) => { + const now = Date.now() + const user = (await Config.getGlobal()).username?.trim() + const schedule = normalizeSchedule(input.schedule) ?? null + const enabled = input.enabled ?? !!schedule + const info: Info = { + id: Identifier.descending("automation"), + name: input.name.trim(), + projects: input.projects, + prompt: input.prompt, + schedule, + enabled, + time: { + created: now, + updated: now, + }, + nextRun: enabled ? nextRun(schedule, now) : undefined, + ...(user ? { createdBy: user, updatedBy: user } : {}), + } + await Storage.write(["automation", info.id], info) + emit(Event.Created, info) + return info + }) + + export const update = fn(UpdateInput, async (input) => { + const now = Date.now() + const user = (await Config.getGlobal()).username?.trim() + const schedule = normalizeSchedule(input.schedule) + const result = await Storage.update(["automation", input.id], (draft) => { + if (input.name !== undefined) draft.name = input.name.trim() + if (input.projects !== undefined) draft.projects = input.projects + if (input.prompt !== undefined) draft.prompt = input.prompt + if (schedule !== undefined) draft.schedule = schedule + if (input.enabled !== undefined) draft.enabled = input.enabled + if (schedule !== undefined || input.enabled !== undefined) { + draft.nextRun = shouldSchedule(draft) ? nextRun(draft.schedule, now) : undefined + } + draft.time.updated = now + if (user) draft.updatedBy = user + }) + emit(Event.Updated, result) + return result + }) + + export const preview = fn( + z.object({ + schedule: ScheduleInput, + }), + async (input) => { + const schedule = normalizeSchedule(input.schedule) + if (!schedule) return { valid: true } + return previewSchedule(schedule, Date.now()) + }, + ) + + export async function list() { + const keys = await Storage.list(["automation"]) + const rootKeys = keys.filter((key) => key.length === 2) + const items = await Promise.all(rootKeys.map((key) => Storage.read(key).catch(() => undefined))) + + return items.filter((item): item is Info => !!item).sort((a, b) => (b.time.updated ?? 0) - (a.time.updated ?? 0)) + } + + export async function get(id: string) { + return Storage.read(["automation", id]) + } + + export const history = fn( + z.object({ + id: Identifier.schema("automation"), + limit: z.number().int().positive().optional(), + }), + async (input) => { + await get(input.id) + const keys = await Storage.list(["automation", input.id, "run"]) + const items = await Promise.all(keys.map((key) => Storage.read(key).catch(() => undefined))) + const runs = items + .filter((item): item is Run => !!item) + .map((run) => { + const time = Number.isFinite(run.time) && run.time > 0 ? run.time : Identifier.timestampDescending(run.id) + return { ...run, time } + }) + .filter((run) => Number.isFinite(run.time) && run.time > 0) + .sort((a, b) => b.time - a.time) + if (input.limit) return runs.slice(0, input.limit) + return runs + }, + ) + + export const clearHistory = fn( + z.object({ + id: Identifier.schema("automation").optional(), + }), + async (input) => { + const ids = input.id ? [input.id] : (await list()).map((info) => info.id) + const runs = input.id + ? await Storage.list(["automation", input.id, "run"]) + : (await Storage.list(["automation"])).filter((key) => key.length > 2 && key[2] === "run") + + if (runs.length === 0 && ids.length === 0) return { cleared: 0 } + + await Promise.all(runs.map((key) => Storage.remove(key))) + await Promise.all( + ids.map((id) => + Storage.update(["automation", id], (draft) => { + draft.lastRun = undefined + draft.lastSession = undefined + }) + .then((updated) => { + emit(Event.Updated, updated) + return updated + }) + .catch(() => undefined), + ), + ) + + return { cleared: runs.length } + }, + ) + + export const remove = fn(Identifier.schema("automation"), async (id) => { + const info = await get(id) + const runs = await Storage.list(["automation", id, "run"]) + await Promise.all(runs.map((key) => Storage.remove(key))) + await Storage.remove(["automation", id]) + emit(Event.Deleted, info) + return info + }) + + export async function init() { + Scheduler.register({ + id: "automation.schedule", + interval, + run: tick, + scope: "global", + }) + } + + export const run = fn( + z.object({ + id: Identifier.schema("automation"), + }), + async (input) => { + const info = await get(input.id) + void execute(info).catch((error) => { + log.error("automation manual run failed", { + id: info.id, + error: error instanceof Error ? error.message : String(error), + }) + }) + return info + }, + ) + + async function tick() { + const now = Date.now() + const automations = await list() + + const due = automations.filter((info) => { + if (!shouldSchedule(info)) return false + + const next = info.nextRun ?? nextRun(info.schedule, now) + if (!next) return false + + return next <= now + }) + if (due.length === 0) return + + await Promise.allSettled(due.map((info) => execute(info))) + } + + async function execute(info: Info) { + if (running.has(info.id)) return info + running.add(info.id) + try { + const started = Date.now() + const rawPrompt = info.prompt.trim() + if (!rawPrompt) { + const completed = Date.now() + const updated = await Storage.update(["automation", info.id], (draft) => { + draft.lastRun = completed + draft.nextRun = shouldSchedule(draft) ? nextRun(draft.schedule, completed) : undefined + draft.time.updated = completed + }) + emit(Event.Updated, updated) + return updated + } + + const projects = info.projects.map((item) => item.trim()).filter(Boolean) + const targets = [...new Set(projects)] + const runs = await Promise.all( + targets.map((directory) => { + let sessionID: string | undefined + return Instance.provide({ + directory, + init: InstanceBootstrap, + fn: async () => { + const prompt = await renderPrompt(rawPrompt, started) + const session = await Session.create({ + title: info.name, + automation: { id: info.id, name: info.name }, + }) + sessionID = session.id + + if (prompt.startsWith("/")) { + const match = prompt.slice(1).match(/^(\S+)\s*(.*)$/s) + const command = match?.[1] + if (!command) return + const args = match?.[2] ?? "" + const parts = await SessionPrompt.resolvePromptParts(args) + const files = parts.filter((part) => part.type === "file") + await SessionPrompt.command({ + sessionID: session.id, + command, + arguments: args, + parts: files, + }) + return + } + + const parts = await SessionPrompt.resolvePromptParts(prompt) + await SessionPrompt.prompt({ sessionID: session.id, parts }) + }, + }) + .then(() => ({ directory, sessionID, status: "success" as const })) + .catch((error) => ({ + directory, + sessionID, + status: "failed" as const, + error: error instanceof Error ? error.message : String(error), + })) + }), + ) + + const failed = runs.filter((result) => result.status === "failed") + if (failed.length > 0) { + log.warn("automation run failed", { + id: info.id, + count: failed.length, + errors: failed.map((result) => result.error), + }) + } + + const entries = runs.map((result) => ({ + id: Identifier.descending("automation_run"), + automationID: info.id, + directory: result.directory, + sessionID: result.sessionID, + status: result.status, + error: result.status === "failed" ? result.error : undefined, + time: started, + })) + + await Promise.all(entries.map((entry) => Storage.write(["automation", info.id, "run", entry.id], entry))) + for (const entry of entries) { + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Event.Run.type, + properties: entry, + }, + }) + } + + const reversed = [...entries].reverse() + const lastSuccess = reversed.find((entry) => entry.sessionID && entry.status === "success") + const lastAny = lastSuccess ?? reversed.find((entry) => entry.sessionID) + const lastSession = lastAny?.sessionID ? { id: lastAny.sessionID, directory: lastAny.directory } : undefined + const completed = Date.now() + + const updated = await Storage.update(["automation", info.id], (draft) => { + draft.lastRun = completed + draft.nextRun = shouldSchedule(draft) ? nextRun(draft.schedule, completed) : undefined + draft.time.updated = completed + draft.lastSession = lastSession + }) + emit(Event.Updated, updated) + return updated + } finally { + running.delete(info.id) + } + } + + function shouldSchedule(info: Info) { + if (!info.enabled) return false + if (!info.schedule) return false + + return true + } + + async function renderPrompt(value: string, now: number) { + const project = Instance.project + const name = project.name?.trim() || getFilename(project.worktree) + const date = new Date(now).toLocaleDateString() + + let result = value.replace(/{{\s*date\s*}}/gi, date) + result = result.replace(/{{\s*project\.name\s*}}/gi, name) + + if (!/{{\s*session\./i.test(result)) return result + + const sessions: Session.Info[] = [] + for await (const session of Session.list()) { + sessions.push(session) + } + sessions.sort((a, b) => b.time.updated - a.time.updated) + + const latest = sessions[0] + const latestLabel = latest ? `Latest session: ${latest.title} (${latest.id})` : "Latest session: none" + + result = result.replace(/{{\s*session\.latest\s*}}/gi, latestLabel) + result = result.replace(/{{\s*session\.query\s*:\s*([^}]+)\s*}}/gi, (_, query: string) => { + const term = query.trim().toLowerCase() + if (!term) return "" + const matches = sessions.filter((session) => session.title.toLowerCase().includes(term)) + const limited = matches.slice(0, 3) + if (limited.length === 0) return "No matching sessions" + return limited.map((session) => `- ${session.title} (${session.id})`).join("\n") + }) + + return result + } + + function normalizeSchedule(value: string | null | undefined) { + if (value === undefined) return undefined + if (value === null) return null + const trimmed = value.trim() + if (!trimmed) return null + + return trimmed + } + + function previewSchedule(schedule: string, now: number): Preview { + const result = parseNexts(schedule, now) + if (result.error) return { valid: false, error: result.error } + + return { valid: true, nextRun: Math.min(...result.nexts) } + } + + function nextRun(schedule: string | null, now: number) { + if (!schedule) return + + const result = parseNexts(schedule, now) + if (result.error) { + log.warn("invalid schedule", { schedule, error: result.error }) + return + } + + return Math.min(...result.nexts) + } + + function parseNexts(schedule: string, now: number) { + const items = schedule + .split(/\r?\n/) + .map((value) => value.trim()) + .filter(Boolean) + if (items.length === 0) return { nexts: [], error: "Schedule is empty" } + + const parsed = items.map((item) => parseNext(item, now)) + const error = parsed.find((result) => result.error)?.error + if (error) return { nexts: [], error } + + const values = parsed.map((result) => result.next) + const nexts = values.filter((value): value is number => typeof value === "number") + if (nexts.length === 0) return { nexts: [], error: "No future runs found" } + + return { nexts } + } + + function parseNext(item: string, now: number) { + // cron-parser throws on invalid expressions; centralize the catch here. + try { + const parsed = parseExpression(item, { currentDate: new Date(now) }) + const next = parsed.next() + if (next instanceof Date) return { next: next.getTime() } + if (typeof next.toDate === "function") return { next: next.toDate().getTime() } + if (typeof next.getTime === "function") return { next: next.getTime() } + + return { next: undefined } + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid schedule" + return { error: message } + } + } + + function emit(event: (typeof Event)[keyof typeof Event], info: Info) { + GlobalBus.emit("event", { + payload: { + type: event.type, + properties: info, + }, + }) + } +} diff --git a/packages/opencode/src/cli/cmd/automation.ts b/packages/opencode/src/cli/cmd/automation.ts new file mode 100644 index 00000000000..7f78d9bdbd6 --- /dev/null +++ b/packages/opencode/src/cli/cmd/automation.ts @@ -0,0 +1,633 @@ +import path from "path" +import { EOL } from "os" +import type { Argv } from "yargs" +import { Automation } from "../../automation" +import { Locale } from "../../util/locale" +import { bootstrap } from "../bootstrap" +import { cmd } from "./cmd" +import { Flag } from "../../flag/flag" +import { AutomationTransfer } from "@opencode-ai/util/automation-transfer" +import { getFilename } from "@opencode-ai/util/path" +import { slugify } from "@opencode-ai/util/slugify" +import { UI } from "../ui" +import * as prompts from "@clack/prompts" +import { Instance } from "../../project/instance" +import { mkdir } from "fs/promises" +import { Filesystem } from "../../util/filesystem" + +export const AutomationCommand = cmd({ + command: "automation", + describe: "manage automations", + builder: (yargs: Argv) => + yargs + .command(AutomationListCommand) + .command(AutomationHistoryCommand) + .command(AutomationCreateCommand) + .command(AutomationUpdateCommand) + .command(AutomationRemoveCommand) + .command(AutomationRunCommand) + .command(AutomationExportCommand) + .command(AutomationImportCommand) + .demandCommand(), + async handler() {}, +}) + +export const AutomationListCommand = cmd({ + command: "list", + describe: "list automations", + builder: (yargs: Argv) => + yargs + .option("max-count", { + alias: "n", + describe: "limit to N most recent automations", + type: "number", + }) + .option("format", { + describe: "output format", + type: "string", + choices: ["table", "json"], + default: "table", + }), + handler: async (args) => { + await bootstrap(process.cwd(), async () => { + const items = await Automation.list() + const limited = args.maxCount ? items.slice(0, args.maxCount) : items + if (limited.length === 0) return + + const output = args.format === "json" ? formatAutomationJSON(limited) : formatAutomationTable(limited) + const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table" + + if (shouldPaginate) { + const proc = Bun.spawn({ + cmd: pagerCmd(), + stdin: "pipe", + stdout: "inherit", + stderr: "inherit", + }) + proc.stdin.write(output) + proc.stdin.end() + await proc.exited + return + } + + process.stdout.write(output + EOL) + }) + }, +}) + +export const AutomationCreateCommand = cmd({ + command: "create", + describe: "create an automation", + builder: (yargs: Argv) => + yargs + .option("name", { + type: "string", + describe: "automation name", + demandOption: true, + }) + .option("prompt", { + type: "string", + describe: "prompt template", + demandOption: true, + }) + .option("project", { + alias: "p", + type: "string", + array: true, + describe: "project directories", + demandOption: true, + }) + .option("schedule", { + type: "string", + describe: "cron schedule (optional)", + }) + .option("enabled", { + type: "boolean", + describe: "enable schedule", + }), + handler: async (args) => { + await bootstrap(process.cwd(), async () => { + const result = await Automation.create({ + name: args.name, + prompt: args.prompt, + projects: args.project ?? [], + schedule: args.schedule ?? null, + enabled: args.enabled, + }) + process.stdout.write(JSON.stringify(result, null, 2) + EOL) + }) + }, +}) + +export const AutomationHistoryCommand = cmd({ + command: "history ", + describe: "show automation run history", + builder: (yargs: Argv) => + yargs + .positional("id", { + type: "string", + describe: "automation id", + demandOption: true, + }) + .option("max-count", { + alias: "n", + describe: "limit to N most recent runs", + type: "number", + }) + .option("format", { + describe: "output format", + type: "string", + choices: ["table", "json"], + default: "table", + }), + handler: async (args) => { + await bootstrap(process.cwd(), async () => { + const items = await Automation.history({ id: args.id, limit: args.maxCount }) + if (items.length === 0) return + + const output = args.format === "json" ? formatRunJSON(items) : formatRunTable(items) + const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table" + + if (shouldPaginate) { + const proc = Bun.spawn({ + cmd: pagerCmd(), + stdin: "pipe", + stdout: "inherit", + stderr: "inherit", + }) + proc.stdin.write(output) + proc.stdin.end() + await proc.exited + return + } + + process.stdout.write(output + EOL) + }) + }, +}) + +export const AutomationUpdateCommand = cmd({ + command: "update ", + describe: "update an automation", + builder: (yargs: Argv) => + yargs + .positional("id", { + type: "string", + describe: "automation id", + demandOption: true, + }) + .option("name", { + type: "string", + describe: "automation name", + }) + .option("prompt", { + type: "string", + describe: "prompt template", + }) + .option("project", { + alias: "p", + type: "string", + array: true, + describe: "project directories", + }) + .option("schedule", { + type: "string", + describe: "cron schedule (optional)", + }) + .option("enabled", { + type: "boolean", + describe: "enable schedule", + }), + handler: async (args) => { + await bootstrap(process.cwd(), async () => { + const changes = { + id: args.id, + name: args.name, + prompt: args.prompt, + projects: args.project, + schedule: args.schedule, + enabled: args.enabled, + } + + if (!hasUpdate(changes)) return + + const result = await Automation.update(changes) + process.stdout.write(JSON.stringify(result, null, 2) + EOL) + }) + }, +}) + +export const AutomationRemoveCommand = cmd({ + command: "remove ", + describe: "remove an automation", + builder: (yargs: Argv) => + yargs.positional("id", { + type: "string", + describe: "automation id", + demandOption: true, + }), + handler: async (args) => { + await bootstrap(process.cwd(), async () => { + await Automation.remove(args.id) + process.stdout.write(`Removed automation: ${args.id}` + EOL) + }) + }, +}) + +export const AutomationRunCommand = cmd({ + command: "run ", + describe: "run an automation", + builder: (yargs: Argv) => + yargs.positional("id", { + type: "string", + describe: "automation id", + demandOption: true, + }), + handler: async (args) => { + await bootstrap(process.cwd(), async () => { + const result = await Automation.run({ id: args.id }) + process.stdout.write(JSON.stringify(result, null, 2) + EOL) + }) + }, +}) + +export const AutomationExportCommand = cmd({ + command: "export [id]", + describe: "export automation definitions as JSON", + builder: (yargs: Argv) => + yargs + .positional("id", { + type: "string", + describe: "automation id", + }) + .option("dir", { + type: "string", + describe: "export to a directory (writes a JSON file)", + }) + .option("project", { + type: "boolean", + default: false, + describe: "export to .opencode/automations in the project root", + }) + .option("all", { + type: "boolean", + default: false, + describe: "export all automations", + }), + handler: async (args) => { + await bootstrap(process.cwd(), async () => { + const items = await Automation.list() + if (items.length === 0) return + + const dir = await resolveAutomationDir({ dir: args.dir, project: args.project }) + if (dir) { + await mkdir(dir, { recursive: true }) + } + + if (args.all) { + if (dir) { + await writeExportFile(dir, "automations.json", items) + return + } + process.stdout.write(JSON.stringify(AutomationTransfer.serialize(items), null, 2) + EOL) + return + } + + let selected = args.id + + if (!selected) { + UI.empty() + prompts.intro("Export automation", { output: process.stderr }) + + const chosen = await prompts.autocomplete({ + message: "Select automation to export", + maxItems: 10, + options: items.map((automation) => ({ + label: automation.name, + value: automation.id, + hint: `${new Date(automation.time.updated).toLocaleString()} • ${automation.id.slice(-8)}`, + })), + output: process.stderr, + }) + + if (prompts.isCancel(chosen)) throw new UI.CancelledError() + selected = chosen as string + prompts.outro("Exporting automation...", { output: process.stderr }) + } + + const match = items.find((item) => item.id === selected) + if (!match) { + UI.error(`Automation not found: ${selected}`) + process.exit(1) + } + if (dir) { + const suffix = slugify(match.name) || match.id.slice(-8) + await writeExportFile(dir, `automation-${suffix}.json`, [match]) + return + } + + process.stdout.write(JSON.stringify(AutomationTransfer.serialize([match]), null, 2) + EOL) + }) + }, +}) + +export const AutomationImportCommand = cmd({ + command: "import [file]", + describe: "import automations from JSON file", + builder: (yargs: Argv) => + yargs + .positional("file", { + type: "string", + describe: "path to JSON file", + }) + .option("dir", { + type: "string", + describe: "import automations from all JSON files in a directory", + }) + .option("project", { + type: "boolean", + default: false, + describe: "import from .opencode/automations in the project root", + }), + handler: async (args) => { + await bootstrap(process.cwd(), async () => { + const dir = await resolveAutomationDir({ dir: args.dir, project: args.project }) + const file = args.file + if (!dir) { + if (!file) { + UI.error("Missing file path or --dir/--project option") + process.exit(1) + } + const items = await readImportsFromFile(file) + if (!items) return + if (items.length === 0) return + + const created = await Promise.all( + items.map((item) => + Automation.create({ + name: item.name, + prompt: item.prompt, + projects: item.projects, + schedule: item.schedule ?? null, + enabled: item.enabled, + }), + ), + ) + + process.stdout.write(`Imported ${created.length} automation${created.length === 1 ? "" : "s"}` + EOL) + return + } + + const items = await readImportsFromDir(dir) + if (!items) return + if (items.length === 0) return + + const created = await Promise.all( + items.map((item) => + Automation.create({ + name: item.name, + prompt: item.prompt, + projects: item.projects, + schedule: item.schedule ?? null, + enabled: item.enabled, + }), + ), + ) + + process.stdout.write(`Imported ${created.length} automation${created.length === 1 ? "" : "s"}` + EOL) + }) + }, +}) + +function scheduleLabel(info: Automation.Info) { + if (!info.schedule) return "Manual" + + const lines = info.schedule + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + + if (!info.enabled) { + const schedule = lines[0] ?? info.schedule + return `Disabled - ${schedule}` + } + if (lines.length > 1) return `Multiple - ${lines.length}` + return lines[0] ?? info.schedule +} + +function runLabel(value?: number) { + if (!value) return "Never" + + return Locale.todayTimeOrDateTime(value) +} + +function resolveAutomationDir(input: { dir?: string; project?: boolean }) { + if (input.dir) return path.resolve(input.dir) + if (!input.project) return + + const root = Instance.worktree && Instance.worktree !== "/" ? Instance.worktree : Instance.directory + return path.join(root, ".opencode", "automations") +} + +async function writeExportFile(dir: string, filename: string, items: Automation.Info[]) { + if (items.length === 0) return + + const filepath = path.join(dir, filename) + await mkdir(dir, { recursive: true }) + await Bun.write(filepath, JSON.stringify(AutomationTransfer.serialize(items), null, 2)) + UI.println(`Exported ${items.length} automation${items.length === 1 ? "" : "s"} to ${filepath}`) +} + +function parseImportPayload(data: unknown) { + return AutomationTransfer.parse(data) +} + +async function readImportsFromFile(filepath: string) { + const file = Bun.file(filepath) + const exists = await file.exists() + if (!exists) { + process.stdout.write(`File not found: ${filepath}` + EOL) + return + } + + const data = await file + .text() + .then((text) => JSON.parse(text)) + .catch(() => undefined) + if (!data) { + process.stdout.write(`Invalid automation import file: ${filepath}` + EOL) + return + } + const items = parseImportPayload(data) + if (items.length === 0) { + process.stdout.write(`Invalid automation import file: ${filepath}` + EOL) + return + } + return items +} + +async function readImportsFromDir(dir: string) { + const exists = await Filesystem.isDir(dir) + if (!exists) { + process.stdout.write(`Directory not found: ${dir}` + EOL) + return + } + + const glob = new Bun.Glob("*.json") + const files: string[] = [] + for await (const match of glob.scan({ cwd: dir, absolute: true })) { + files.push(match) + } + if (files.length === 0) { + process.stdout.write(`No automation exports found in: ${dir}` + EOL) + return + } + + const items = ( + await Promise.all( + files.map((file) => + Bun.file(file) + .json() + .then((data) => parseImportPayload(data)) + .catch(() => []), + ), + ) + ).flat() + + if (items.length === 0) { + process.stdout.write(`No valid automations found in: ${dir}` + EOL) + return + } + return items +} + +function formatAutomationTable(items: Automation.Info[]): string { + const rows = items.map((item) => ({ + id: item.id, + name: item.name, + projects: String(item.projects.length), + schedule: scheduleLabel(item), + next: runLabel(item.nextRun), + last: runLabel(item.lastRun), + })) + + const maxId = Math.max("Automation ID".length, ...rows.map((row) => row.id.length)) + const maxName = Math.min(32, Math.max("Name".length, ...rows.map((row) => row.name.length))) + const maxProjects = Math.max("Projects".length, ...rows.map((row) => row.projects.length)) + const maxSchedule = Math.min(32, Math.max("Schedule".length, ...rows.map((row) => row.schedule.length))) + const maxNext = Math.max("Next Run".length, ...rows.map((row) => row.next.length)) + const maxLast = Math.max("Last Run".length, ...rows.map((row) => row.last.length)) + + const header = + `Automation ID${" ".repeat(maxId - "Automation ID".length)}` + + ` Name${" ".repeat(maxName - "Name".length)}` + + ` Projects${" ".repeat(maxProjects - "Projects".length)}` + + ` Schedule${" ".repeat(maxSchedule - "Schedule".length)}` + + ` Next Run${" ".repeat(maxNext - "Next Run".length)}` + + ` Last Run${" ".repeat(maxLast - "Last Run".length)}` + + const lines = [header, "-".repeat(header.length)] + + for (const row of rows) { + const line = + row.id.padEnd(maxId) + + " " + + Locale.truncate(row.name, maxName).padEnd(maxName) + + " " + + row.projects.padEnd(maxProjects) + + " " + + Locale.truncate(row.schedule, maxSchedule).padEnd(maxSchedule) + + " " + + row.next.padEnd(maxNext) + + " " + + row.last.padEnd(maxLast) + lines.push(line) + } + + return lines.join(EOL) +} + +function formatAutomationJSON(items: Automation.Info[]): string { + return JSON.stringify(items, null, 2) +} + +function formatRunTable(items: Automation.Run[]): string { + const rows = items.map((item) => ({ + time: Locale.todayTimeOrDateTime(item.time), + project: getFilename(item.directory), + status: item.status, + session: item.sessionID ?? "-", + })) + + const maxTime = Math.max("Time".length, ...rows.map((row) => row.time.length)) + const maxProject = Math.max("Project".length, ...rows.map((row) => row.project.length)) + const maxStatus = Math.max("Status".length, ...rows.map((row) => row.status.length)) + const maxSession = Math.max("Session".length, ...rows.map((row) => row.session.length)) + + const header = + `Time${" ".repeat(maxTime - "Time".length)}` + + ` Project${" ".repeat(maxProject - "Project".length)}` + + ` Status${" ".repeat(maxStatus - "Status".length)}` + + ` Session${" ".repeat(maxSession - "Session".length)}` + + const lines = [header, "-".repeat(header.length)] + + for (const row of rows) { + const line = + row.time.padEnd(maxTime) + + " " + + row.project.padEnd(maxProject) + + " " + + row.status.padEnd(maxStatus) + + " " + + row.session.padEnd(maxSession) + lines.push(line) + } + + return lines.join(EOL) +} + +function formatRunJSON(items: Automation.Run[]): string { + return JSON.stringify(items, null, 2) +} + +function hasUpdate(input: { + name?: string + prompt?: string + projects?: string[] + schedule?: string + enabled?: boolean +}) { + if (input.name !== undefined) return true + if (input.prompt !== undefined) return true + if (input.projects !== undefined) return true + if (input.schedule !== undefined) return true + if (input.enabled !== undefined) return true + + return false +} + +function pagerCmd(): string[] { + const lessOptions = ["-R", "-S"] + if (process.platform !== "win32") { + return ["less", ...lessOptions] + } + + const lessOnPath = Bun.which("less") + if (lessOnPath) { + if (Bun.file(lessOnPath).size) return [lessOnPath, ...lessOptions] + } + + if (Flag.OPENCODE_GIT_BASH_PATH) { + const less = path.join(Flag.OPENCODE_GIT_BASH_PATH, "..", "..", "usr", "bin", "less.exe") + if (Bun.file(less).size) return [less, ...lessOptions] + } + + const git = Bun.which("git") + if (git) { + const less = path.join(git, "..", "..", "usr", "bin", "less.exe") + if (Bun.file(less).size) return [less, ...lessOptions] + } + + return ["cmd", "/c", "more"] +} diff --git a/packages/opencode/src/cli/cmd/generate.ts b/packages/opencode/src/cli/cmd/generate.ts index fad4514c81e..e42be0983dd 100644 --- a/packages/opencode/src/cli/cmd/generate.ts +++ b/packages/opencode/src/cli/cmd/generate.ts @@ -14,7 +14,7 @@ export const GenerateCommand = { { lang: "js", source: [ - `import { createOpencodeClient } from "@opencode-ai/sdk`, + `import { createOpencodeClient } from "@opencode-ai/sdk"`, ``, `const client = createOpencodeClient()`, `await client.${operation.operationId}({`, diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 0d5aefe7bc3..c05fc22a9ee 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -18,6 +18,7 @@ import { DialogHelp } from "./ui/dialog-help" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" +import { DialogAutomationList } from "@tui/component/dialog-automation-list" import { KeybindProvider } from "@tui/context/keybind" import { ThemeProvider, useTheme } from "@tui/context/theme" import { Home } from "@tui/routes/home" @@ -326,6 +327,15 @@ function App() { dialog.replace(() => ) }, }, + { + title: "Automations", + value: "automation.list", + keybind: "automation_list", + category: "Automation", + onSelect: () => { + dialog.replace(() => ) + }, + }, { title: "New session", suggested: route.data.type === "session", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-automation-history.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-automation-history.tsx new file mode 100644 index 00000000000..72a8cdc87b3 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-automation-history.tsx @@ -0,0 +1,77 @@ +import { DialogSelect } from "@tui/ui/dialog-select" +import { useDialog } from "@tui/ui/dialog" +import { useSDK } from "@tui/context/sdk" +import { useRoute } from "@tui/context/route" +import { useTheme } from "@tui/context/theme" +import { useToast } from "@tui/ui/toast" +import { useSync } from "@tui/context/sync" +import { Locale } from "@/util/locale" +import { getFilename } from "@opencode-ai/util/path" +import { createMemo, onMount } from "solid-js" +import { createStore } from "solid-js/store" +import type { Automation, AutomationRun } from "@opencode-ai/sdk/v2" + +export function DialogAutomationHistory(props: { automation: Automation }) { + const dialog = useDialog() + const sdk = useSDK() + const route = useRoute() + const toast = useToast() + const sync = useSync() + const { theme } = useTheme() + + const [store, setStore] = createStore({ + runs: [] as AutomationRun[], + }) + + onMount(() => { + dialog.setSize("large") + sdk.client.automation + .history({ automationID: props.automation.id, limit: 25 }) + .then((result) => { + setStore("runs", result.data ?? []) + }) + .catch(() => { + toast.show({ message: "Failed to load history", variant: "error" }) + }) + }) + + const options = createMemo(() => + store.runs.map((run) => { + const status = run.status === "success" ? "Success" : "Failed" + const gutter = run.status === "success" ? S : F + const footer = run.sessionID ? `Session: ${run.sessionID}` : "Session: -" + const project = getFilename(run.directory) + const description = `${project} - ${status}` + return { + title: Locale.todayTimeOrDateTime(run.time), + description, + footer, + gutter, + value: run, + } + }), + ) + + const canOpenSession = (directory: string) => { + if (!directory) return false + if (sync.data.path.worktree && sync.data.path.worktree !== "/" && directory === sync.data.path.worktree) return true + if (directory === sync.data.path.directory) return true + return false + } + + return ( + { + if (!option.value.sessionID) return + if (!canOpenSession(option.value.directory)) { + toast.show({ message: "Open this session from its project", variant: "error" }) + return + } + route.navigate({ type: "session", sessionID: option.value.sessionID }) + }} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-automation-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-automation-list.tsx new file mode 100644 index 00000000000..aa1bac60382 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-automation-list.tsx @@ -0,0 +1,456 @@ +import { DialogSelect } from "@tui/ui/dialog-select" +import { DialogPrompt } from "@tui/ui/dialog-prompt" +import { useDialog } from "@tui/ui/dialog" +import { useSync } from "@tui/context/sync" +import { useSDK } from "@tui/context/sdk" +import { useRoute } from "@tui/context/route" +import { useTheme } from "@tui/context/theme" +import { useKeybind } from "@tui/context/keybind" +import { useToast } from "@tui/ui/toast" +import { Locale } from "@/util/locale" +import { Filesystem } from "@/util/filesystem" +import { AutomationTransfer } from "@opencode-ai/util/automation-transfer" +import { mkdir } from "fs/promises" +import path from "path" +import { getFilename } from "@opencode-ai/util/path" +import { slugify } from "@opencode-ai/util/slugify" +import { createMemo, onMount } from "solid-js" +import { reconcile } from "solid-js/store" +import type { Automation, Project } from "@opencode-ai/sdk/v2" +import { DialogAutomationHistory } from "@tui/component/dialog-automation-history" + +const templateHint = "Template variables are available" + +function projectLabel(project: Project) { + return project.name || getFilename(project.worktree) +} + +function scheduleLabel(automation: Automation) { + if (!automation.schedule) return "Manual" + + const lines = automation.schedule + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + + if (!automation.enabled) { + const schedule = lines[0] ?? automation.schedule + return `Disabled - ${schedule}` + } + if (lines.length > 1) return `Multiple - ${lines.length}` + return lines[0] ?? automation.schedule +} + +function formatRun(value?: number) { + if (!value) return "Never" + + return Locale.todayTimeOrDateTime(value) +} + +export function DialogAutomationList() { + const dialog = useDialog() + const sync = useSync() + const sdk = useSDK() + const route = useRoute() + const { theme } = useTheme() + const keybind = useKeybind() + const toast = useToast() + const keys = keybind.all + + const refreshAutomations = async () => { + const list = await sdk.client.automation.list() + const items = (list.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)) + sync.set("automation", reconcile(items)) + } + + const projectRoot = () => { + if (sync.data.path.worktree && sync.data.path.worktree !== "/") return sync.data.path.worktree + if (sync.data.path.directory) return sync.data.path.directory + + return process.cwd() + } + const projectAutomationDir = () => path.join(projectRoot(), ".opencode", "automations") + const canOpenSession = (directory: string) => { + if (!directory) return false + if (sync.data.path.worktree && sync.data.path.worktree !== "/" && directory === sync.data.path.worktree) return true + if (directory === sync.data.path.directory) return true + return false + } + + const writeProjectExport = async (items: Automation[], filename: string) => { + if (items.length === 0) { + toast.show({ message: "No automations to export", variant: "error" }) + return + } + + const dir = projectAutomationDir() + await mkdir(dir, { recursive: true }) + const output = JSON.stringify(AutomationTransfer.serialize(items), null, 2) + const filepath = path.join(dir, filename) + await Bun.write(filepath, output) + toast.show({ + message: `Exported to ${path.join(".opencode", "automations", path.basename(filepath))}`, + variant: "success", + }) + } + + const exportSelected = async (automation: Automation) => { + const suffix = slugify(automation.name) || automation.id.slice(-8) + const filename = `automation-${suffix}.json` + await writeProjectExport([automation], filename) + } + + const exportAll = async () => { + await writeProjectExport(sync.data.automation, "automations.json") + } + + const importAutomations = async () => { + const dir = projectAutomationDir() + const exists = await Filesystem.isDir(dir) + if (!exists) { + toast.show({ message: "No .opencode/automations directory", variant: "error" }) + return + } + + const glob = new Bun.Glob("*.json") + const files: string[] = [] + for await (const match of glob.scan({ cwd: dir, absolute: true })) { + files.push(match) + } + if (files.length === 0) { + toast.show({ message: "No automation exports found", variant: "error" }) + return + } + + const items = ( + await Promise.all( + files.map((file) => + Bun.file(file) + .json() + .then((data) => AutomationTransfer.parse(data)) + .catch(() => undefined), + ), + ) + ).flatMap((item) => item ?? []) + + if (items.length === 0) { + toast.show({ message: "Import failed", variant: "error" }) + return + } + + const results = await Promise.all( + items.map((item) => + sdk.client.automation + .create({ + name: item.name, + prompt: item.prompt, + projects: item.projects, + schedule: item.schedule ?? null, + enabled: item.enabled, + }) + .then(() => true) + .catch(() => false), + ), + ) + const success = results.filter(Boolean).length + if (success === 0) { + toast.show({ message: "Import failed", variant: "error" }) + return + } + toast.show({ message: `Imported ${success} automation${success === 1 ? "" : "s"}`, variant: "success" }) + await refreshAutomations() + } + + const options = createMemo(() => + sync.data.automation + .slice() + .toSorted((a, b) => b.time.updated - a.time.updated) + .map((automation) => { + const projectCount = automation.projects.length + const projects = Locale.pluralize(projectCount, "{} project", "{} projects") + const schedule = scheduleLabel(automation) + const summary = `${projects} - ${schedule}` + const footer = `Next: ${formatRun(automation.nextRun)} | Last: ${formatRun(automation.lastRun)}` + return { + title: automation.name || "Untitled", + description: summary, + footer, + value: automation, + } + }), + ) + + onMount(() => { + dialog.setSize("large") + }) + + const promptName = (value?: string) => + DialogPrompt.show(dialog, "Automation name", { + placeholder: "Daily summary", + value, + }) + + const promptPrompt = (value?: string) => + DialogPrompt.show(dialog, "Prompt", { + placeholder: "Summarize today's progress", + value, + description: () => Templates: {templateHint}, + }) + + const promptProjects = async (value?: string) => { + const list = await sdk.client.project.list() + const projects = (list.data ?? []).filter((item) => item.worktree && item.worktree !== "/") + const available = projects.map((item) => projectLabel(item)).join(", ") + const input = await DialogPrompt.show(dialog, "Projects", { + placeholder: "project-a, project-b", + value, + description: () => ( + Comma-separated names or paths. Available: {available || "none"} + ), + }) + if (input === null) return null + return { input, projects } + } + + const promptSchedule = (value?: string) => + DialogPrompt.show(dialog, "Cron schedule", { + placeholder: "0 9 * * 1-5 (empty for manual)", + value, + }) + + const promptEnabled = (value?: string) => + DialogPrompt.show(dialog, "Enable schedule? (y/n)", { + placeholder: "y", + value, + }) + + const resolveProjects = (input: string, projects: Project[]) => { + const entries = input + .split(",") + .map((item) => item.trim()) + .filter(Boolean) + if (entries.length === 0) return [] + if (entries.length === 1 && entries[0] === "*") { + return projects.map((item) => item.worktree) + } + return entries.map((entry) => { + const match = projects.find((item) => { + if (item.worktree === entry) return true + if (item.name === entry) return true + if (getFilename(item.worktree) === entry) return true + return false + }) + return match?.worktree ?? entry + }) + } + + const returnToList = () => { + dialog.replace(() => ) + } + + const createAutomation = async () => { + const name = await promptName() + if (name === null) return returnToList() + if (!name.trim()) return returnToList() + + const prompt = await promptPrompt() + if (prompt === null) return returnToList() + if (!prompt.trim()) return returnToList() + + const projectsData = await promptProjects() + if (!projectsData) return returnToList() + const projectValues = resolveProjects(projectsData.input, projectsData.projects) + if (projectValues.length === 0) return returnToList() + + const scheduleInput = await promptSchedule() + if (scheduleInput === null) return returnToList() + const schedule = scheduleInput.trim() + const savedSchedule = schedule ? schedule : null + + let enabled = false + if (schedule) { + const enabledInput = await promptEnabled("y") + if (enabledInput === null) return returnToList() + const normalized = enabledInput.trim().toLowerCase() + enabled = normalized.length === 0 || normalized.startsWith("y") + } + + await sdk.client.automation.create({ + name: name.trim(), + prompt: prompt.trim(), + projects: projectValues, + schedule: savedSchedule, + enabled, + }) + await refreshAutomations() + dialog.replace(() => ) + } + + const editAutomation = async (automation: Automation) => { + const name = await promptName(automation.name) + if (name === null) return returnToList() + const prompt = await promptPrompt(automation.prompt) + if (prompt === null) return returnToList() + + const projectsDefault = automation.projects.join(", ") + const projectsData = await promptProjects(projectsDefault) + if (!projectsData) return returnToList() + const projectsInput = projectsData.input.trim() + const projectValues = projectsInput ? resolveProjects(projectsInput, projectsData.projects) : automation.projects + + const scheduleInput = await promptSchedule(automation.schedule ?? "") + if (scheduleInput === null) return returnToList() + const schedule = scheduleInput.trim() + const savedSchedule = schedule ? schedule : null + + let enabled = false + if (schedule) { + const enabledInput = await promptEnabled(automation.enabled ? "y" : "n") + if (enabledInput === null) return returnToList() + enabled = enabledInput.trim().toLowerCase().startsWith("y") + } + + await sdk.client.automation.update({ + automationID: automation.id, + name: name.trim() || automation.name, + prompt: prompt.trim() || automation.prompt, + projects: projectValues.length ? projectValues : automation.projects, + schedule: savedSchedule, + enabled, + }) + await refreshAutomations() + dialog.replace(() => ) + } + + const confirmDelete = async () => { + const input = await DialogPrompt.show(dialog, "Delete automation", { + placeholder: "type DELETE", + description: () => This permanently removes the automation., + }) + dialog.replace(() => ) + return input + } + + const clearHistory = async () => { + const input = await DialogPrompt.show(dialog, "Clear run history", { + placeholder: "type CLEAR", + description: () => Clears run history for all automations., + }) + dialog.replace(() => ) + if (input === null) return + if (input.trim().toLowerCase() !== "clear") return + await sdk.client.automation.clearHistory() + await refreshAutomations() + } + + return ( + { + editAutomation(option.value) + }} + keybind={[ + { + keybind: keys.automation_create?.[0], + title: "create", + requiresSelection: false, + onTrigger: () => { + createAutomation() + }, + }, + { + keybind: keys.automation_run?.[0], + title: "run", + onTrigger: async (option) => { + if (!option) return + await sdk.client.automation.run({ automationID: option.value.id }) + await refreshAutomations() + }, + }, + { + keybind: keys.automation_open?.[0], + title: "open", + onTrigger: (option) => { + if (!option) return + const session = option.value.lastSession + if (!session) return + if (!canOpenSession(session.directory)) { + toast.show({ message: "Open this session from its project", variant: "error" }) + return + } + route.navigate({ type: "session", sessionID: session.id }) + }, + }, + { + keybind: keys.automation_history?.[0], + title: "history", + onTrigger: (option) => { + if (!option) return + dialog.replace( + () => , + () => { + setTimeout(() => { + dialog.replace(() => ) + }, 0) + }, + ) + }, + }, + { + keybind: keys.automation_export?.[0], + title: "export", + onTrigger: (option) => { + if (!option) return + exportSelected(option.value) + }, + }, + { + keybind: keys.automation_export_all?.[0], + title: "export all", + requiresSelection: false, + onTrigger: () => { + exportAll() + }, + }, + { + keybind: keys.automation_import?.[0], + title: "import", + requiresSelection: false, + onTrigger: () => { + importAutomations() + }, + }, + { + keybind: keys.automation_clear_history?.[0], + title: "clear history", + requiresSelection: false, + onTrigger: () => { + clearHistory() + }, + }, + { + keybind: keys.automation_edit?.[0], + title: "edit", + onTrigger: (option) => { + if (!option) return + editAutomation(option.value) + }, + }, + { + keybind: keys.automation_delete?.[0], + title: "delete", + onTrigger: async (option) => { + if (!option) return + const input = await confirmDelete() + if (input === null) return + if (input.trim() !== "DELETE") return + await sdk.client.automation.remove({ automationID: option.value.id }) + await refreshAutomations() + }, + }, + ]} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx index 9cfa30d4df9..6f75ff0a1c4 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx @@ -49,7 +49,8 @@ export function DialogMcp() { { keybind: Keybind.parse("space")[0], title: "toggle", - onTrigger: async (option: DialogSelectOption) => { + onTrigger: async (option?: DialogSelectOption) => { + if (!option) return // Prevent toggling while an operation is already in progress if (loading() !== null) return diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 4ad92eeb839..13e5c8a76dc 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -210,6 +210,7 @@ export function DialogModel(props: { providerID?: string }) { { keybind: keybind.all.model_provider_list?.[0], title: connected() ? "Connect provider" : "View all providers", + requiresSelection: false, onTrigger() { dialog.replace(() => ) }, @@ -219,6 +220,7 @@ export function DialogModel(props: { providerID?: string }) { title: "Favorite", disabled: !connected(), onTrigger: (option) => { + if (!option) return local.model.toggleFavorite(option.value as { providerID: string; modelID: string }) }, }, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 775969bfcb3..14a18ffd7b3 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -2,7 +2,7 @@ import { useDialog } from "@tui/ui/dialog" import { DialogSelect } from "@tui/ui/dialog-select" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" -import { createMemo, createSignal, createResource, onMount, Show } from "solid-js" +import { createMemo, createSignal, createResource, onMount } from "solid-js" import { Locale } from "@/util/locale" import { useKeybind } from "../context/keybind" import { useTheme } from "../context/theme" @@ -48,13 +48,15 @@ export function DialogSessionList() { const isDeleting = toDelete() === x.id const status = sync.data.session_status?.[x.id] const isWorking = status?.type === "busy" + const automation = x.automation ? A : undefined + const gutter = isWorking ? : automation return { title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title, bg: isDeleting ? theme.error : undefined, value: x.id, category, footer: Locale.time(x.time.updated), - gutter: isWorking ? : undefined, + gutter, } }) }) @@ -85,6 +87,7 @@ export function DialogSessionList() { keybind: keybind.all.session_delete?.[0], title: "delete", onTrigger: async (option) => { + if (!option) return if (toDelete() === option.value) { sdk.client.session.delete({ sessionID: option.value, @@ -99,6 +102,7 @@ export function DialogSessionList() { keybind: keybind.all.session_rename?.[0], title: "rename", onTrigger: async (option) => { + if (!option) return dialog.replace(() => ) }, }, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx index e8664f6289b..970c3c7ef0a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx @@ -73,6 +73,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { keybind: keybind.all.stash_delete?.[0], title: "delete", onTrigger: (option) => { + if (!option) return if (toDelete() === option.value) { stash.remove(option.value) setToDelete(undefined) diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx index 0dbbbc6f9ee..9a7fd1c2c28 100644 --- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx @@ -72,10 +72,34 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex return store.leader }, parse(evt: ParsedKey): Keybind.Info { + if (typeof evt.name === "string" && evt.name.length === 1) { + const code = evt.name.charCodeAt(0) + if (code >= 1 && code <= 26) { + return Keybind.fromParsedKey( + { + ...evt, + name: String.fromCharCode(code + 96), + ctrl: true, + }, + store.leader, + ) + } + } + // Handle special case for Ctrl+Underscore (represented as \x1F) if (evt.name === "\x1F") { return Keybind.fromParsedKey({ ...evt, name: "_", ctrl: true }, store.leader) } + + if (typeof evt.name === "string" && evt.name.length === 1) { + return Keybind.fromParsedKey( + { + ...evt, + name: evt.name.toLowerCase(), + }, + store.leader, + ) + } return Keybind.fromParsedKey(evt, store.leader) }, match(key: keyof KeybindsConfig, evt: ParsedKey) { diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index eb8ed2d9bba..8c20529ce86 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -17,6 +17,7 @@ import type { ProviderListResponse, ProviderAuthMethod, VcsInfo, + Automation, } from "@opencode-ai/sdk/v2" import { createStore, produce, reconcile } from "solid-js/store" import { useSDK } from "@tui/context/sdk" @@ -73,6 +74,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ formatter: FormatterStatus[] vcs: VcsInfo | undefined path: Path + automation: Automation[] }>({ provider_next: { all: [], @@ -100,6 +102,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ formatter: [], vcs: undefined, path: { state: "", config: "", worktree: "", directory: "" }, + automation: [], }) const sdk = useSDK() @@ -322,6 +325,34 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setStore("vcs", { branch: event.properties.branch }) break } + case "automation.created": + case "automation.updated": { + const result = Binary.search(store.automation, event.properties.id, (s) => s.id) + if (result.found) { + setStore("automation", result.index, reconcile(event.properties)) + break + } + + setStore( + "automation", + produce((draft) => { + draft.splice(result.index, 0, event.properties) + }), + ) + break + } + case "automation.deleted": { + const result = Binary.search(store.automation, event.properties.id, (s) => s.id) + if (!result.found) break + + setStore( + "automation", + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + break + } } }) @@ -392,6 +423,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.client.session.status().then((x) => { setStore("session_status", reconcile(x.data!)) }), + sdk.client.automation.list().then((x) => { + const list = (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)) + setStore("automation", reconcile(list)) + }), sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))), sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))), sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))), diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 7792900bcfe..a7ecdd3cb3b 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -24,12 +24,13 @@ export interface DialogSelectProps { keybind?: Keybind.Info title: string disabled?: boolean - onTrigger: (option: DialogSelectOption) => void + requiresSelection?: boolean + onTrigger: (option?: DialogSelectOption) => void }[] current?: T } -export interface DialogSelectOption { +export interface DialogSelectOption { title: string value: T description?: string @@ -54,6 +55,7 @@ export function DialogSelect(props: DialogSelectProps) { selected: 0, filter: "", input: "keyboard" as "keyboard" | "mouse", + keybindPage: 0, }) createEffect( @@ -105,7 +107,6 @@ export function DialogSelect(props: DialogSelectProps) { const result = pipe( filtered(), groupBy((x) => x.category ?? ""), - // mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))), entries(), ) return result @@ -178,6 +179,21 @@ export function DialogSelect(props: DialogSelectProps) { useKeyboard((evt) => { setStore("input", "keyboard") + const typing = typeof evt.name === "string" && evt.name.length === 1 && !evt.ctrl && !evt.meta && !evt.shift + + if (!typing) { + for (const item of props.keybind ?? []) { + if (item.disabled || !item.keybind) continue + if (!Keybind.match(item.keybind, keybind.parse(evt))) continue + const s = selected() + if (!s && item.requiresSelection !== false) continue + evt.preventDefault() + evt.stopPropagation() + item.onTrigger(s) + return + } + } + if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1) if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1) if (evt.name === "pageup") move(-10) @@ -195,15 +211,11 @@ export function DialogSelect(props: DialogSelectProps) { } } - for (const item of props.keybind ?? []) { - if (item.disabled || !item.keybind) continue - if (Keybind.match(item.keybind, keybind.parse(evt))) { - const s = selected() - if (s) { - evt.preventDefault() - item.onTrigger(s) - } - } + if (evt.name === "tab" && keybindPageCount() > 1) { + evt.preventDefault() + evt.stopPropagation() + moveKeybindPage(evt.shift ? -1 : 1) + return } }) @@ -219,6 +231,33 @@ export function DialogSelect(props: DialogSelectProps) { props.ref?.(ref) const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? []) + const keybindPageSize = 4 + const keybindPageCount = createMemo(() => { + const size = keybindPageSize + if (size <= 0) return 1 + return Math.max(1, Math.ceil(keybinds().length / size)) + }) + const keybindPage = createMemo(() => Math.min(store.keybindPage, keybindPageCount() - 1)) + const pagedKeybinds = createMemo(() => { + const size = keybindPageSize + const start = keybindPage() * size + return keybinds().slice(start, start + size) + }) + + const moveKeybindPage = (direction: number) => { + if (keybindPageCount() <= 1) return + const total = keybindPageCount() + let next = keybindPage() + direction + if (next < 0) next = total - 1 + if (next >= total) next = 0 + setStore("keybindPage", next) + } + + createEffect(() => { + if (store.keybindPage >= keybindPageCount()) { + setStore("keybindPage", Math.max(0, keybindPageCount() - 1)) + } + }) return ( @@ -335,17 +374,24 @@ export function DialogSelect(props: DialogSelectProps) { }> - - - {(item) => ( - - - {item.title}{" "} - - {Keybind.toString(item.keybind)} - - )} - + + + + {(item) => ( + + + {item.title}{" "} + + {Keybind.toString(item.keybind)} + + )} + + + 1}> + + tab {keybindPage() + 1}/{keybindPageCount()} + + diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index a231a530072..9c634f9a83b 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -225,7 +225,30 @@ export namespace Config { result.share = "auto" } - if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({}) + const keybinds = (result.keybinds ?? {}) as Record + const defaults = Info.shape.keybinds.parse({}) + const base = defaults as NonNullable + const merged = { ...base, ...keybinds } as typeof defaults + const legacyAutomationDefaults: Record = { + automation_create: ["ctrl+n", "alt+n", "n"], + automation_run: ["ctrl+r", "alt+r", "r"], + automation_open: ["ctrl+o", "alt+o", "o"], + automation_history: ["ctrl+h", "alt+h", "h"], + automation_edit: ["ctrl+e", "alt+e", "e"], + automation_delete: ["ctrl+d", "alt+d", "d"], + automation_clear_history: ["ctrl+l", "alt+l", "l"], + automation_export: ["ctrl+x", "alt+x", "x"], + automation_export_all: ["ctrl+shift+x", "alt+shift+x", "shift+x"], + automation_import: ["ctrl+i", "alt+i", "i"], + } + const normalized: Record = {} + for (const [key, legacy] of Object.entries(legacyAutomationDefaults)) { + const value = keybinds[key] + if (!value) continue + if (!legacy.includes(value)) continue + normalized[key] = base[key as keyof typeof base] + } + result.keybinds = { ...merged, ...normalized } as typeof defaults // Apply flag overrides for compaction settings if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) { @@ -771,6 +794,17 @@ export namespace Config { session_export: z.string().optional().default("x").describe("Export session to editor"), session_new: z.string().optional().default("n").describe("Create a new session"), session_list: z.string().optional().default("l").describe("List all sessions"), + automation_list: z.string().optional().default("o").describe("List automations"), + automation_create: z.string().optional().default("shift+n").describe("Create automation"), + automation_run: z.string().optional().default("shift+r").describe("Run automation"), + automation_open: z.string().optional().default("shift+o").describe("Open automation session"), + automation_history: z.string().optional().default("shift+h").describe("View automation history"), + automation_edit: z.string().optional().default("shift+e").describe("Edit automation"), + automation_delete: z.string().optional().default("shift+d").describe("Delete automation"), + automation_clear_history: z.string().optional().default("shift+l").describe("Clear automation history"), + automation_export: z.string().optional().default("shift+x").describe("Export automation"), + automation_export_all: z.string().optional().default("shift+a").describe("Export all automations"), + automation_import: z.string().optional().default("shift+i").describe("Import automations"), session_timeline: z.string().optional().default("g").describe("Show session timeline"), session_fork: z.string().optional().default("none").describe("Fork session from message"), session_rename: z.string().optional().default("ctrl+r").describe("Rename session"), diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index db2920b0a45..08c8097c950 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -11,6 +11,8 @@ export namespace Identifier { part: "prt", pty: "pty", tool: "tool", + automation: "aut", + automation_run: "atr", } as const export function schema(prefix: keyof typeof prefixes) { @@ -80,4 +82,14 @@ export namespace Identifier { const encoded = BigInt("0x" + hex) return Number(encoded / BigInt(0x1000)) } + + /** Extract timestamp from a descending ID. */ + export function timestampDescending(id: string): number { + const prefix = id.split("_")[0] + const hex = id.slice(prefix.length + 1, prefix.length + 13) + const encoded = BigInt("0x" + hex) + const mask = (BigInt(1) << BigInt(48)) - BigInt(1) + const restored = ~encoded & mask + return Number(restored / BigInt(0x1000)) + } } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 6dc5e99e91e..bd945d01f7b 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -26,6 +26,7 @@ import { EOL } from "os" import { WebCommand } from "./cli/cmd/web" import { PrCommand } from "./cli/cmd/pr" import { SessionCommand } from "./cli/cmd/session" +import { AutomationCommand } from "./cli/cmd/automation" process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { @@ -97,6 +98,7 @@ const cli = yargs(hideBin(process.argv)) .command(GithubCommand) .command(PrCommand) .command(SessionCommand) + .command(AutomationCommand) .fail((msg, err) => { if ( msg?.startsWith("Unknown argument") || diff --git a/packages/opencode/src/server/routes/automation.ts b/packages/opencode/src/server/routes/automation.ts new file mode 100644 index 00000000000..1678590a309 --- /dev/null +++ b/packages/opencode/src/server/routes/automation.ts @@ -0,0 +1,234 @@ +import { describeRoute, resolver, validator } from "hono-openapi" +import { Hono } from "hono" +import { Automation } from "../../automation" +import { lazy } from "../../util/lazy" +import { errors } from "../error" +import z from "zod" + +export const AutomationRoutes = lazy(() => + new Hono() + .get( + "/", + describeRoute({ + summary: "List automations", + description: "Get a list of all automations.", + operationId: "automation.list", + responses: { + 200: { + description: "List of automations", + content: { + "application/json": { + schema: resolver(Automation.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const result = await Automation.list() + return c.json(result) + }, + ) + .post( + "/", + describeRoute({ + summary: "Create automation", + description: "Create a new automation.", + operationId: "automation.create", + responses: { + 200: { + description: "Created automation", + content: { + "application/json": { + schema: resolver(Automation.Info), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", Automation.create.schema), + async (c) => { + const body = c.req.valid("json") + const result = await Automation.create(body) + return c.json(result) + }, + ) + .post( + "/preview", + describeRoute({ + summary: "Preview automation schedule", + description: "Validate a cron schedule and return the next run.", + operationId: "automation.preview", + responses: { + 200: { + description: "Schedule preview", + content: { + "application/json": { + schema: resolver(Automation.Preview), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", Automation.preview.schema), + async (c) => { + const body = c.req.valid("json") + const result = await Automation.preview(body) + return c.json(result) + }, + ) + .delete( + "/history", + describeRoute({ + summary: "Clear automation run history", + description: "Delete all automation run history.", + operationId: "automation.clearHistory", + responses: { + 200: { + description: "History cleared", + content: { + "application/json": { + schema: resolver(Automation.HistoryClear), + }, + }, + }, + ...errors(400), + }, + }), + async (c) => { + const result = await Automation.clearHistory({}) + return c.json(result) + }, + ) + .patch( + "/:automationID", + describeRoute({ + summary: "Update automation", + description: "Update an existing automation.", + operationId: "automation.update", + responses: { + 200: { + description: "Updated automation", + content: { + "application/json": { + schema: resolver(Automation.Info), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + automationID: Automation.update.schema.shape.id, + }), + ), + validator("json", Automation.update.schema.omit({ id: true })), + async (c) => { + const automationID = c.req.valid("param").automationID + const body = c.req.valid("json") + const result = await Automation.update({ ...body, id: automationID }) + return c.json(result) + }, + ) + .get( + "/:automationID/history", + describeRoute({ + summary: "List automation run history", + description: "Get recent run history for an automation.", + operationId: "automation.history", + responses: { + 200: { + description: "Automation run history", + content: { + "application/json": { + schema: resolver(Automation.Run.array()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + automationID: Automation.update.schema.shape.id, + }), + ), + validator( + "query", + z.object({ + limit: z.coerce.number().int().positive().optional(), + }), + ), + async (c) => { + const automationID = c.req.valid("param").automationID + const query = c.req.valid("query") + const result = await Automation.history({ id: automationID, limit: query.limit }) + return c.json(result) + }, + ) + .delete( + "/:automationID", + describeRoute({ + summary: "Delete automation", + description: "Delete an automation.", + operationId: "automation.remove", + responses: { + 200: { + description: "Deleted automation", + content: { + "application/json": { + schema: resolver(Automation.Info), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + automationID: Automation.update.schema.shape.id, + }), + ), + async (c) => { + const automationID = c.req.valid("param").automationID + const result = await Automation.remove(automationID) + return c.json(result) + }, + ) + .post( + "/:automationID/run", + describeRoute({ + summary: "Run automation", + description: "Manually run an automation.", + operationId: "automation.run", + responses: { + 200: { + description: "Automation run started", + content: { + "application/json": { + schema: resolver(Automation.Info), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + automationID: Automation.update.schema.shape.id, + }), + ), + async (c) => { + const automationID = c.req.valid("param").automationID + const result = await Automation.run({ id: automationID }) + return c.json(result) + }, + ), +) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 015553802a4..7f99ee2328c 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -21,6 +21,7 @@ import { Auth } from "../auth" import { Flag } from "../flag/flag" import { Command } from "../command" import { Global } from "../global" +import { Automation } from "../automation" import { ProjectRoutes } from "./routes/project" import { SessionRoutes } from "./routes/session" import { PtyRoutes } from "./routes/pty" @@ -39,6 +40,7 @@ import { errors } from "./error" import { QuestionRoutes } from "./routes/question" import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" +import { AutomationRoutes } from "./routes/automation" import { MDNS } from "./mdns" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 @@ -122,6 +124,7 @@ export namespace Server { }), ) .route("/global", GlobalRoutes()) + .route("/automation", AutomationRoutes()) .put( "/auth/:providerID", describeRoute({ @@ -570,6 +573,7 @@ export namespace Server { mdnsDomain?: string cors?: string[] }) { + Automation.init() _corsWhitelist = opts.cors ?? [] const args = { diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 556fad01f59..63b4ca598e2 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -86,6 +86,12 @@ export namespace Session { diff: z.string().optional(), }) .optional(), + automation: z + .object({ + id: Identifier.schema("automation"), + name: z.string().optional(), + }) + .optional(), }) .meta({ ref: "Session", @@ -143,6 +149,7 @@ export namespace Session { parentID: Identifier.schema("session").optional(), title: z.string().optional(), permission: Info.shape.permission, + automation: Info.shape.automation, }) .optional(), async (input) => { @@ -151,6 +158,7 @@ export namespace Session { directory: Instance.directory, title: input?.title, permission: input?.permission, + automation: input?.automation, }) }, ) @@ -209,6 +217,7 @@ export namespace Session { parentID?: string directory: string permission?: PermissionNext.Ruleset + automation?: Info["automation"] }) { const result: Info = { id: Identifier.descending("session", input.id), @@ -219,6 +228,7 @@ export namespace Session { parentID: input.parentID, title: input.title ?? createDefaultTitle(!!input.parentID), permission: input.permission, + automation: input.automation, time: { created: Date.now(), updated: Date.now(), diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 18f2d67e7ac..c20c72334ca 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -192,6 +192,7 @@ export namespace Storage { const dir = await state().then((x) => x.dir) const target = path.join(dir, ...key) + ".json" return withErrorHandling(async () => { + await fs.mkdir(path.dirname(target), { recursive: true }) using _ = await Lock.write(target) await Bun.write(target, JSON.stringify(content, null, 2)) }) diff --git a/packages/opencode/test/automation/transfer.test.ts b/packages/opencode/test/automation/transfer.test.ts new file mode 100644 index 00000000000..479b6285638 --- /dev/null +++ b/packages/opencode/test/automation/transfer.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test" +import { AutomationTransfer } from "@opencode-ai/util/automation-transfer" + +describe("automation.transfer", () => { + test("serializes automation exports with version", () => { + const result = AutomationTransfer.serialize([ + { + name: "Daily Review", + projects: ["/repo/a"], + prompt: "Summarize changes", + schedule: "0 9 * * 1-5", + enabled: true, + }, + ]) + + expect(result.version).toBe(1) + expect(result.automations).toHaveLength(1) + expect(result.automations[0]?.name).toBe("Daily Review") + }) + + test("parses valid payload and normalizes projects", () => { + const result = AutomationTransfer.parse({ + automations: [ + { + name: "Nightly", + projects: [" /repo/a ", "/repo/a", "", " /repo/b"], + prompt: "Run checks", + schedule: null, + enabled: false, + }, + ], + }) + + expect(result).toHaveLength(1) + expect(result[0]?.projects).toEqual(["/repo/a", "/repo/b"]) + }) + + test("accepts array payload shorthand", () => { + const result = AutomationTransfer.parse([ + { + name: "Morning", + projects: ["/repo/a"], + prompt: "Ping", + }, + ]) + + expect(result).toHaveLength(1) + expect(result[0]?.name).toBe("Morning") + }) + + test("returns empty list for invalid payload", () => { + expect(AutomationTransfer.parse({ automations: [{ name: "x" }] })).toEqual([]) + expect(AutomationTransfer.parse(null)).toEqual([]) + }) +}) diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 623c16a8114..d62a6f76062 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -8,6 +8,14 @@ import { Log } from "../../src/util/log" const projectRoot = path.join(__dirname, "../..") Log.init({ print: false }) +function auth(): Record { + const password = process.env.OPENCODE_SERVER_PASSWORD + if (!password) return {} + const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode" + const value = Buffer.from(`${username}:${password}`).toString("base64") + return { Authorization: `Basic ${value}` } +} + describe("session.list", () => { test("filters by directory", async () => { await Instance.provide({ @@ -23,7 +31,9 @@ describe("session.list", () => { fn: async () => Session.create({}), }) - const response = await app.request(`/session?directory=${encodeURIComponent(projectRoot)}`) + const response = await app.request(`/session?directory=${encodeURIComponent(projectRoot)}`, { + headers: auth(), + }) expect(response.status).toBe(200) const body = (await response.json()) as unknown[] diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts index 479be4a17f8..0e9937beef0 100644 --- a/packages/opencode/test/server/session-select.test.ts +++ b/packages/opencode/test/server/session-select.test.ts @@ -8,6 +8,14 @@ import { Server } from "../../src/server/server" const projectRoot = path.join(__dirname, "../..") Log.init({ print: false }) +function auth(): Record { + const password = process.env.OPENCODE_SERVER_PASSWORD + if (!password) return {} + const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode" + const value = Buffer.from(`${username}:${password}`).toString("base64") + return { Authorization: `Basic ${value}` } +} + describe("tui.selectSession endpoint", () => { test("should return 200 when called with valid session", async () => { await Instance.provide({ @@ -20,7 +28,7 @@ describe("tui.selectSession endpoint", () => { const app = Server.App() const response = await app.request("/tui/select-session", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...auth() }, body: JSON.stringify({ sessionID: session.id }), }) @@ -45,7 +53,7 @@ describe("tui.selectSession endpoint", () => { const app = Server.App() const response = await app.request("/tui/select-session", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...auth() }, body: JSON.stringify({ sessionID: nonExistentSessionID }), }) @@ -66,7 +74,7 @@ describe("tui.selectSession endpoint", () => { const app = Server.App() const response = await app.request("/tui/select-session", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...auth() }, body: JSON.stringify({ sessionID: invalidSessionID }), }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index b757b753507..d751227f810 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -13,6 +13,21 @@ import type { AuthRemoveResponses, AuthSetErrors, AuthSetResponses, + AutomationClearHistoryErrors, + AutomationClearHistoryResponses, + AutomationCreateErrors, + AutomationCreateResponses, + AutomationHistoryErrors, + AutomationHistoryResponses, + AutomationListResponses, + AutomationPreviewErrors, + AutomationPreviewResponses, + AutomationRemoveErrors, + AutomationRemoveResponses, + AutomationRunErrors, + AutomationRunResponses, + AutomationUpdateErrors, + AutomationUpdateResponses, CommandListResponses, Config as Config3, ConfigGetResponses, @@ -299,6 +314,209 @@ export class Global extends HeyApiClient { } } +export class Automation extends HeyApiClient { + /** + * List automations + * + * Get a list of all automations. + */ + public list(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/automation", + ...options, + }) + } + + /** + * Create automation + * + * Create a new automation. + */ + public create( + parameters?: { + name?: string + projects?: Array + prompt?: string + schedule?: string | null + enabled?: boolean + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "body", key: "name" }, + { in: "body", key: "projects" }, + { in: "body", key: "prompt" }, + { in: "body", key: "schedule" }, + { in: "body", key: "enabled" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/automation", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Preview automation schedule + * + * Validate a cron schedule and return the next run. + */ + public preview( + parameters?: { + schedule?: string | null + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "schedule" }] }]) + return (options?.client ?? this.client).post({ + url: "/automation/preview", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Clear automation run history + * + * Delete all automation run history. + */ + public clearHistory(options?: Options) { + return (options?.client ?? this.client).delete< + AutomationClearHistoryResponses, + AutomationClearHistoryErrors, + ThrowOnError + >({ url: "/automation/history", ...options }) + } + + /** + * Delete automation + * + * Delete an automation. + */ + public remove( + parameters: { + automationID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "automationID" }] }]) + return (options?.client ?? this.client).delete({ + url: "/automation/{automationID}", + ...options, + ...params, + }) + } + + /** + * Update automation + * + * Update an existing automation. + */ + public update( + parameters: { + automationID: string + name?: string + projects?: Array + prompt?: string + schedule?: string | null + enabled?: boolean + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "automationID" }, + { in: "body", key: "name" }, + { in: "body", key: "projects" }, + { in: "body", key: "prompt" }, + { in: "body", key: "schedule" }, + { in: "body", key: "enabled" }, + ], + }, + ], + ) + return (options?.client ?? this.client).patch({ + url: "/automation/{automationID}", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * List automation run history + * + * Get recent run history for an automation. + */ + public history( + parameters: { + automationID: string + limit?: number + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "automationID" }, + { in: "query", key: "limit" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/automation/{automationID}/history", + ...options, + ...params, + }) + } + + /** + * Run automation + * + * Manually run an automation. + */ + public run( + parameters: { + automationID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "automationID" }] }]) + return (options?.client ?? this.client).post({ + url: "/automation/{automationID}/run", + ...options, + ...params, + }) + } +} + export class Auth extends HeyApiClient { /** * Remove auth credentials @@ -973,6 +1191,10 @@ export class Session extends HeyApiClient { parentID?: string title?: string permission?: PermissionRuleset + automation?: { + id: string + name?: string + } }, options?: Options, ) { @@ -985,6 +1207,7 @@ export class Session extends HeyApiClient { { in: "body", key: "parentID" }, { in: "body", key: "title" }, { in: "body", key: "permission" }, + { in: "body", key: "automation" }, ], }, ], @@ -3191,6 +3414,11 @@ export class OpencodeClient extends HeyApiClient { return (this._global ??= new Global({ client: this.client })) } + private _automation?: Automation + get automation(): Automation { + return (this._automation ??= new Automation({ client: this.client })) + } + private _auth?: Auth get auth(): Auth { return (this._auth ??= new Auth({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index d72c37a28b5..5b109f2b019 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -785,6 +785,10 @@ export type Session = { snapshot?: string diff?: string } + automation?: { + id: string + name?: string + } } export type EventSessionCreated = { @@ -831,6 +835,57 @@ export type EventVcsBranchUpdated = { } } +export type Automation = { + id: string + name: string + projects: Array + prompt: string + schedule: string | null + enabled: boolean + time: { + created: number + updated: number + } + lastRun?: number + nextRun?: number + lastSession?: { + id: string + directory: string + } + createdBy?: string + updatedBy?: string +} + +export type EventAutomationCreated = { + type: "automation.created" + properties: Automation +} + +export type EventAutomationUpdated = { + type: "automation.updated" + properties: Automation +} + +export type EventAutomationDeleted = { + type: "automation.deleted" + properties: Automation +} + +export type AutomationRun = { + id: string + automationID: string + directory: string + sessionID?: string + status: "success" | "failed" + error?: string + time: number +} + +export type EventAutomationRun = { + type: "automation.run" + properties: AutomationRun +} + export type Pty = { id: string title: string @@ -922,6 +977,10 @@ export type Event = | EventSessionDiff | EventSessionError | EventVcsBranchUpdated + | EventAutomationCreated + | EventAutomationUpdated + | EventAutomationDeleted + | EventAutomationRun | EventPtyCreated | EventPtyUpdated | EventPtyExited @@ -982,6 +1041,50 @@ export type KeybindsConfig = { * List all sessions */ session_list?: string + /** + * List automations + */ + automation_list?: string + /** + * Create automation + */ + automation_create?: string + /** + * Run automation + */ + automation_run?: string + /** + * Open automation session + */ + automation_open?: string + /** + * View automation history + */ + automation_history?: string + /** + * Edit automation + */ + automation_edit?: string + /** + * Delete automation + */ + automation_delete?: string + /** + * Clear automation history + */ + automation_clear_history?: string + /** + * Export automation + */ + automation_export?: string + /** + * Export all automations + */ + automation_export_all?: string + /** + * Import automations + */ + automation_import?: string /** * Show session timeline */ @@ -1836,6 +1939,23 @@ export type BadRequestError = { success: false } +export type AutomationPreview = { + valid: boolean + nextRun?: number + error?: string +} + +export type AutomationHistoryClear = { + cleared: number +} + +export type NotFoundError = { + name: "NotFoundError" + data: { + message: string + } +} + export type OAuth = { type: "oauth" refresh: string @@ -1858,13 +1978,6 @@ export type WellKnownAuth = { export type Auth = OAuth | ApiAuth | WellKnownAuth -export type NotFoundError = { - name: "NotFoundError" - data: { - message: string - } -} - export type Model = { id: string providerID: string @@ -2274,6 +2387,237 @@ export type GlobalDisposeResponses = { export type GlobalDisposeResponse = GlobalDisposeResponses[keyof GlobalDisposeResponses] +export type AutomationListData = { + body?: never + path?: never + query?: never + url: "/automation" +} + +export type AutomationListResponses = { + /** + * List of automations + */ + 200: Array +} + +export type AutomationListResponse = AutomationListResponses[keyof AutomationListResponses] + +export type AutomationCreateData = { + body?: { + name: string + projects: Array + prompt: string + schedule?: string | null + enabled?: boolean + } + path?: never + query?: never + url: "/automation" +} + +export type AutomationCreateErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AutomationCreateError = AutomationCreateErrors[keyof AutomationCreateErrors] + +export type AutomationCreateResponses = { + /** + * Created automation + */ + 200: Automation +} + +export type AutomationCreateResponse = AutomationCreateResponses[keyof AutomationCreateResponses] + +export type AutomationPreviewData = { + body?: { + schedule?: string | null + } + path?: never + query?: never + url: "/automation/preview" +} + +export type AutomationPreviewErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AutomationPreviewError = AutomationPreviewErrors[keyof AutomationPreviewErrors] + +export type AutomationPreviewResponses = { + /** + * Schedule preview + */ + 200: AutomationPreview +} + +export type AutomationPreviewResponse = AutomationPreviewResponses[keyof AutomationPreviewResponses] + +export type AutomationClearHistoryData = { + body?: never + path?: never + query?: never + url: "/automation/history" +} + +export type AutomationClearHistoryErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AutomationClearHistoryError = AutomationClearHistoryErrors[keyof AutomationClearHistoryErrors] + +export type AutomationClearHistoryResponses = { + /** + * History cleared + */ + 200: AutomationHistoryClear +} + +export type AutomationClearHistoryResponse = AutomationClearHistoryResponses[keyof AutomationClearHistoryResponses] + +export type AutomationRemoveData = { + body?: never + path: { + automationID: string + } + query?: never + url: "/automation/{automationID}" +} + +export type AutomationRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type AutomationRemoveError = AutomationRemoveErrors[keyof AutomationRemoveErrors] + +export type AutomationRemoveResponses = { + /** + * Deleted automation + */ + 200: Automation +} + +export type AutomationRemoveResponse = AutomationRemoveResponses[keyof AutomationRemoveResponses] + +export type AutomationUpdateData = { + body?: { + name?: string + projects?: Array + prompt?: string + schedule?: string | null + enabled?: boolean + } + path: { + automationID: string + } + query?: never + url: "/automation/{automationID}" +} + +export type AutomationUpdateErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type AutomationUpdateError = AutomationUpdateErrors[keyof AutomationUpdateErrors] + +export type AutomationUpdateResponses = { + /** + * Updated automation + */ + 200: Automation +} + +export type AutomationUpdateResponse = AutomationUpdateResponses[keyof AutomationUpdateResponses] + +export type AutomationHistoryData = { + body?: never + path: { + automationID: string + } + query?: { + limit?: number + } + url: "/automation/{automationID}/history" +} + +export type AutomationHistoryErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type AutomationHistoryError = AutomationHistoryErrors[keyof AutomationHistoryErrors] + +export type AutomationHistoryResponses = { + /** + * Automation run history + */ + 200: Array +} + +export type AutomationHistoryResponse = AutomationHistoryResponses[keyof AutomationHistoryResponses] + +export type AutomationRunData = { + body?: never + path: { + automationID: string + } + query?: never + url: "/automation/{automationID}/run" +} + +export type AutomationRunErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type AutomationRunError = AutomationRunErrors[keyof AutomationRunErrors] + +export type AutomationRunResponses = { + /** + * Automation run started + */ + 200: Automation +} + +export type AutomationRunResponse = AutomationRunResponses[keyof AutomationRunResponses] + export type AuthRemoveData = { body?: never path: { @@ -2871,6 +3215,10 @@ export type SessionCreateData = { parentID?: string title?: string permission?: PermissionRuleset + automation?: { + id: string + name?: string + } } path?: never query?: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index f50cc06c101..e2f33a207e6 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -36,7 +36,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.global.health({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.global.health({\n ...\n})" } ] } @@ -61,7 +61,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.global.event({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.global.event({\n ...\n})" } ] } @@ -86,7 +86,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.global.config.get({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.global.config.get({\n ...\n})" } ] }, @@ -128,7 +128,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.global.config.update({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.global.config.update({\n ...\n})" } ] } @@ -153,7 +153,468 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.global.dispose({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.global.dispose({\n ...\n})" + } + ] + } + }, + "/automation": { + "get": { + "operationId": "automation.list", + "summary": "List automations", + "description": "Get a list of all automations.", + "responses": { + "200": { + "description": "List of automations", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Automation" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.automation.list({\n ...\n})" + } + ] + }, + "post": { + "operationId": "automation.create", + "summary": "Create automation", + "description": "Create a new automation.", + "responses": { + "200": { + "description": "Created automation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Automation" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "projects": { + "minItems": 1, + "type": "array", + "items": { + "type": "string" + } + }, + "prompt": { + "type": "string" + }, + "schedule": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "enabled": { + "type": "boolean" + } + }, + "required": ["name", "projects", "prompt"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.automation.create({\n ...\n})" + } + ] + } + }, + "/automation/preview": { + "post": { + "operationId": "automation.preview", + "summary": "Preview automation schedule", + "description": "Validate a cron schedule and return the next run.", + "responses": { + "200": { + "description": "Schedule preview", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutomationPreview" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "schedule": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.automation.preview({\n ...\n})" + } + ] + } + }, + "/automation/history": { + "delete": { + "operationId": "automation.clearHistory", + "summary": "Clear automation run history", + "description": "Delete all automation run history.", + "responses": { + "200": { + "description": "History cleared", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutomationHistoryClear" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.automation.clearHistory({\n ...\n})" + } + ] + } + }, + "/automation/{automationID}": { + "patch": { + "operationId": "automation.update", + "summary": "Update automation", + "description": "Update an existing automation.", + "responses": { + "200": { + "description": "Updated automation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Automation" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "parameters": [ + { + "in": "path", + "name": "automationID", + "schema": { + "type": "string", + "pattern": "^aut.*" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "projects": { + "minItems": 1, + "type": "array", + "items": { + "type": "string" + } + }, + "prompt": { + "type": "string" + }, + "schedule": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "enabled": { + "type": "boolean" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.automation.update({\n ...\n})" + } + ] + }, + "delete": { + "operationId": "automation.remove", + "summary": "Delete automation", + "description": "Delete an automation.", + "responses": { + "200": { + "description": "Deleted automation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Automation" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "parameters": [ + { + "in": "path", + "name": "automationID", + "schema": { + "type": "string", + "pattern": "^aut.*" + }, + "required": true + } + ], + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.automation.remove({\n ...\n})" + } + ] + } + }, + "/automation/{automationID}/history": { + "get": { + "operationId": "automation.history", + "summary": "List automation run history", + "description": "Get recent run history for an automation.", + "responses": { + "200": { + "description": "Automation run history", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AutomationRun" + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "parameters": [ + { + "in": "path", + "name": "automationID", + "schema": { + "type": "string", + "pattern": "^aut.*" + }, + "required": true + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + } + ], + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.automation.history({\n ...\n})" + } + ] + } + }, + "/automation/{automationID}/run": { + "post": { + "operationId": "automation.run", + "summary": "Run automation", + "description": "Manually run an automation.", + "responses": { + "200": { + "description": "Automation run started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Automation" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "parameters": [ + { + "in": "path", + "name": "automationID", + "schema": { + "type": "string", + "pattern": "^aut.*" + }, + "required": true + } + ], + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.automation.run({\n ...\n})" } ] } @@ -207,7 +668,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.set({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.auth.set({\n ...\n})" } ] }, @@ -250,7 +711,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.remove({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.auth.remove({\n ...\n})" } ] } @@ -287,7 +748,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.project.list({\n ...\n})" } ] } @@ -321,7 +782,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.current({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.project.current({\n ...\n})" } ] } @@ -420,7 +881,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.update({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.project.update({\n ...\n})" } ] } @@ -457,7 +918,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.pty.list({\n ...\n})" } ] }, @@ -534,7 +995,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.create({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.pty.create({\n ...\n})" } ] } @@ -586,7 +1047,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.get({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.pty.get({\n ...\n})" } ] }, @@ -662,7 +1123,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.update({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.pty.update({\n ...\n})" } ] }, @@ -712,7 +1173,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.remove({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.pty.remove({\n ...\n})" } ] } @@ -764,7 +1225,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.connect({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.pty.connect({\n ...\n})" } ] } @@ -798,7 +1259,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.config.get({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.config.get({\n ...\n})" } ] }, @@ -849,7 +1310,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.config.update({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.config.update({\n ...\n})" } ] } @@ -901,7 +1362,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.config.providers({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.config.providers({\n ...\n})" } ] } @@ -945,7 +1406,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tool.ids({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.tool.ids({\n ...\n})" } ] } @@ -1005,7 +1466,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tool.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.tool.list({\n ...\n})" } ] } @@ -1058,7 +1519,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.create({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.worktree.create({\n ...\n})" } ] }, @@ -1093,7 +1554,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.worktree.list({\n ...\n})" } ] }, @@ -1144,7 +1605,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.remove({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.worktree.remove({\n ...\n})" } ] } @@ -1197,7 +1658,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.reset({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.worktree.reset({\n ...\n})" } ] } @@ -1237,7 +1698,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.resource.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.experimental.resource.list({\n ...\n})" } ] } @@ -1307,7 +1768,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.session.list({\n ...\n})" } ] }, @@ -1361,6 +1822,19 @@ }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" + }, + "automation": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^aut.*" + }, + "name": { + "type": "string" + } + }, + "required": ["id"] } } } @@ -1370,7 +1844,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.create({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.session.create({\n ...\n})" } ] } @@ -1420,7 +1894,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.status({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.session.status({\n ...\n})" } ] } @@ -1484,7 +1958,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.get({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.session.get({\n ...\n})" } ] }, @@ -1545,7 +2019,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.delete({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.session.delete({\n ...\n})" } ] }, @@ -1627,7 +2101,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.update({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.session.update({\n ...\n})" } ] } @@ -1694,7 +2168,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.children({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.session.children({\n ...\n})" } ] } @@ -1760,7 +2234,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.todo({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.session.todo({\n ...\n})" } ] } @@ -1845,7 +2319,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.init({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.session.init({\n ...\n})" } ] } @@ -1903,7 +2377,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.fork({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.session.fork({\n ...\n})" } ] } @@ -1965,7 +2439,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.abort({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.session.abort({\n ...\n})" } ] } @@ -2027,7 +2501,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.share({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.session.share({\n ...\n})" } ] }, @@ -2088,7 +2562,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.unshare({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.session.unshare({\n ...\n})" } ] } @@ -2142,7 +2616,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.diff({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.session.diff({\n ...\n})" } ] } @@ -2227,7 +2701,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.summarize({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.session.summarize({\n ...\n})" } ] } @@ -2312,7 +2786,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.messages({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.session.messages({\n ...\n})" } ] }, @@ -2454,7 +2928,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.prompt({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.session.prompt({\n ...\n})" } ] } @@ -2538,7 +3012,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.message({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.session.message({\n ...\n})" } ] } @@ -2618,7 +3092,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.part.delete({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.part.delete({\n ...\n})" } ] }, @@ -2705,7 +3179,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.part.update({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.part.update({\n ...\n})" } ] } @@ -2830,7 +3304,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.prompt_async({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.session.prompt_async({\n ...\n})" } ] } @@ -2968,7 +3442,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.command({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.session.command({\n ...\n})" } ] } @@ -3061,7 +3535,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.shell({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.session.shell({\n ...\n})" } ] } @@ -3143,7 +3617,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.revert({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.session.revert({\n ...\n})" } ] } @@ -3205,7 +3679,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.unrevert({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.session.unrevert({\n ...\n})" } ] } @@ -3292,7 +3766,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.respond({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.permission.respond({\n ...\n})" } ] } @@ -3373,7 +3847,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.reply({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.permission.reply({\n ...\n})" } ] } @@ -3410,7 +3884,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.permission.list({\n ...\n})" } ] } @@ -3447,7 +3921,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.question.list({\n ...\n})" } ] } @@ -3528,7 +4002,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reply({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.question.reply({\n ...\n})" } ] } @@ -3590,7 +4064,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reject({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.question.reject({\n ...\n})" } ] } @@ -3850,7 +4324,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.provider.list({\n ...\n})" } ] } @@ -3893,7 +4367,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.auth({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.provider.auth({\n ...\n})" } ] } @@ -3962,7 +4436,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.oauth.authorize({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.provider.oauth.authorize({\n ...\n})" } ] } @@ -4035,7 +4509,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.oauth.callback({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.provider.oauth.callback({\n ...\n})" } ] } @@ -4131,7 +4605,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.text({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.find.text({\n ...\n})" } ] } @@ -4201,7 +4675,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.files({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.find.files({\n ...\n})" } ] } @@ -4246,7 +4720,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.symbols({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.find.symbols({\n ...\n})" } ] } @@ -4291,7 +4765,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.file.list({\n ...\n})" } ] } @@ -4333,7 +4807,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.read({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.file.read({\n ...\n})" } ] } @@ -4370,7 +4844,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.status({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.file.status({\n ...\n})" } ] } @@ -4410,7 +4884,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.status({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.mcp.status({\n ...\n})" } ] }, @@ -4483,7 +4957,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.add({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.mcp.add({\n ...\n})" } ] } @@ -4552,7 +5026,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.start({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.start({\n ...\n})" } ] }, @@ -4609,7 +5083,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.remove({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.remove({\n ...\n})" } ] } @@ -4687,7 +5161,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.callback({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.callback({\n ...\n})" } ] } @@ -4749,7 +5223,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.authenticate({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.authenticate({\n ...\n})" } ] } @@ -4790,7 +5264,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.connect({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.mcp.connect({\n ...\n})" } ] } @@ -4831,7 +5305,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.disconnect({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.mcp.disconnect({\n ...\n})" } ] } @@ -4890,7 +5364,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.appendPrompt({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.tui.appendPrompt({\n ...\n})" } ] } @@ -4924,7 +5398,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.openHelp({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.tui.openHelp({\n ...\n})" } ] } @@ -4958,7 +5432,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.openSessions({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.tui.openSessions({\n ...\n})" } ] } @@ -4992,7 +5466,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.openThemes({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.tui.openThemes({\n ...\n})" } ] } @@ -5026,7 +5500,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.openModels({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.tui.openModels({\n ...\n})" } ] } @@ -5060,7 +5534,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.submitPrompt({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.tui.submitPrompt({\n ...\n})" } ] } @@ -5094,7 +5568,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.clearPrompt({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.tui.clearPrompt({\n ...\n})" } ] } @@ -5153,7 +5627,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.executeCommand({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.tui.executeCommand({\n ...\n})" } ] } @@ -5214,7 +5688,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.showToast({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.tui.showToast({\n ...\n})" } ] } @@ -5280,7 +5754,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.publish({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.tui.publish({\n ...\n})" } ] } @@ -5351,7 +5825,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.selectSession({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.tui.selectSession({\n ...\n})" } ] } @@ -5392,7 +5866,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.control.next({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.tui.control.next({\n ...\n})" } ] } @@ -5433,7 +5907,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.control.response({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.tui.control.response({\n ...\n})" } ] } @@ -5467,7 +5941,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.instance.dispose({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.instance.dispose({\n ...\n})" } ] } @@ -5501,7 +5975,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.path.get({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.path.get({\n ...\n})" } ] } @@ -5535,7 +6009,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.get({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.vcs.get({\n ...\n})" } ] } @@ -5572,7 +6046,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.command.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.command.list({\n ...\n})" } ] } @@ -5649,7 +6123,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.log({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.app.log({\n ...\n})" } ] } @@ -5686,7 +6160,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.agents({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.app.agents({\n ...\n})" } ] } @@ -5738,7 +6212,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.skills({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.app.skills({\n ...\n})" } ] } @@ -5775,7 +6249,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.lsp.status({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.lsp.status({\n ...\n})" } ] } @@ -5812,7 +6286,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.formatter.status({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.formatter.status({\n ...\n})" } ] } @@ -5846,7 +6320,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.event.subscribe({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.event.subscribe({\n ...\n})" } ] } @@ -7981,6 +8455,19 @@ } }, "required": ["messageID"] + }, + "automation": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^aut.*" + }, + "name": { + "type": "string" + } + }, + "required": ["id"] } }, "required": ["id", "slug", "projectID", "directory", "title", "version", "time"] @@ -8122,6 +8609,161 @@ }, "required": ["type", "properties"] }, + "Automation": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^aut.*" + }, + "name": { + "type": "string" + }, + "projects": { + "type": "array", + "items": { + "type": "string" + } + }, + "prompt": { + "type": "string" + }, + "schedule": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "enabled": { + "type": "boolean" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "updated": { + "type": "number" + } + }, + "required": ["created", "updated"] + }, + "lastRun": { + "type": "number" + }, + "nextRun": { + "type": "number" + }, + "lastSession": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^ses.*" + }, + "directory": { + "type": "string" + } + }, + "required": ["id", "directory"] + }, + "createdBy": { + "type": "string" + }, + "updatedBy": { + "type": "string" + } + }, + "required": ["id", "name", "projects", "prompt", "schedule", "enabled", "time"] + }, + "Event.automation.created": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "automation.created" + }, + "properties": { + "$ref": "#/components/schemas/Automation" + } + }, + "required": ["type", "properties"] + }, + "Event.automation.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "automation.updated" + }, + "properties": { + "$ref": "#/components/schemas/Automation" + } + }, + "required": ["type", "properties"] + }, + "Event.automation.deleted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "automation.deleted" + }, + "properties": { + "$ref": "#/components/schemas/Automation" + } + }, + "required": ["type", "properties"] + }, + "AutomationRun": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^atr.*" + }, + "automationID": { + "type": "string", + "pattern": "^aut.*" + }, + "directory": { + "type": "string" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "status": { + "type": "string", + "enum": ["success", "failed"] + }, + "error": { + "type": "string" + }, + "time": { + "type": "number" + } + }, + "required": ["id", "automationID", "directory", "status", "time"] + }, + "Event.automation.run": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "automation.run" + }, + "properties": { + "$ref": "#/components/schemas/AutomationRun" + } + }, + "required": ["type", "properties"] + }, "Pty": { "type": "object", "properties": { @@ -8386,6 +9028,18 @@ { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, + { + "$ref": "#/components/schemas/Event.automation.created" + }, + { + "$ref": "#/components/schemas/Event.automation.updated" + }, + { + "$ref": "#/components/schemas/Event.automation.deleted" + }, + { + "$ref": "#/components/schemas/Event.automation.run" + }, { "$ref": "#/components/schemas/Event.pty.created" }, @@ -8477,6 +9131,61 @@ "default": "l", "type": "string" }, + "automation_list": { + "description": "List automations", + "default": "o", + "type": "string" + }, + "automation_create": { + "description": "Create automation", + "default": "shift+n", + "type": "string" + }, + "automation_run": { + "description": "Run automation", + "default": "shift+r", + "type": "string" + }, + "automation_open": { + "description": "Open automation session", + "default": "shift+o", + "type": "string" + }, + "automation_history": { + "description": "View automation history", + "default": "shift+h", + "type": "string" + }, + "automation_edit": { + "description": "Edit automation", + "default": "shift+e", + "type": "string" + }, + "automation_delete": { + "description": "Delete automation", + "default": "shift+d", + "type": "string" + }, + "automation_clear_history": { + "description": "Clear automation history", + "default": "shift+l", + "type": "string" + }, + "automation_export": { + "description": "Export automation", + "default": "shift+x", + "type": "string" + }, + "automation_export_all": { + "description": "Export all automations", + "default": "shift+a", + "type": "string" + }, + "automation_import": { + "description": "Import automations", + "default": "shift+i", + "type": "string" + }, "session_timeline": { "description": "Show session timeline", "default": "g", @@ -9926,6 +10635,49 @@ }, "required": ["data", "errors", "success"] }, + "AutomationPreview": { + "type": "object", + "properties": { + "valid": { + "type": "boolean" + }, + "nextRun": { + "type": "number" + }, + "error": { + "type": "string" + } + }, + "required": ["valid"] + }, + "AutomationHistoryClear": { + "type": "object", + "properties": { + "cleared": { + "type": "number" + } + }, + "required": ["cleared"] + }, + "NotFoundError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "NotFoundError" + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"] + } + }, + "required": ["name", "data"] + }, "OAuth": { "type": "object", "properties": { @@ -9993,25 +10745,6 @@ } ] }, - "NotFoundError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "const": "NotFoundError" - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - }, - "required": ["name", "data"] - }, "Model": { "type": "object", "properties": { diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 544c6abdd21..a1b9f7dd969 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -2,6 +2,7 @@ import { splitProps, type ComponentProps } from "solid-js" const icons = { "align-right": ``, + automate: ``, "arrow-up": ``, "arrow-left": ``, "arrow-right": ``, @@ -31,6 +32,7 @@ const icons = { "magnifying-glass": ``, "plus-small": ``, plus: ``, + play: ``, "pencil-line": ``, mcp: ``, glasses: ``, @@ -59,6 +61,7 @@ const icons = { check: ``, photo: ``, share: ``, + sparkles: ``, download: ``, menu: ``, server: ``, diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index b370dbb6456..df04d8f1572 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -1,5 +1,5 @@ import { Select as Kobalte } from "@kobalte/core/select" -import { createMemo, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js" +import { Show, createMemo, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js" import { pipe, groupBy, entries, map } from "remeda" import { Button, ButtonProps } from "./button" import { Icon } from "./icon" @@ -19,6 +19,7 @@ export type SelectProps = Omit>, "value" | " children?: (item: T | undefined) => JSX.Element triggerStyle?: JSX.CSSProperties triggerVariant?: "settings" + portal?: boolean } export function Select(props: SelectProps & Omit) { @@ -38,6 +39,7 @@ export function Select(props: SelectProps & Omit) "children", "triggerStyle", "triggerVariant", + "portal", ]) const state = { @@ -154,18 +156,34 @@ export function Select(props: SelectProps & Omit) - - - - - + + + + } + > + + + + + + ) } diff --git a/packages/util/src/automation-transfer.ts b/packages/util/src/automation-transfer.ts new file mode 100644 index 00000000000..cb76304ce0b --- /dev/null +++ b/packages/util/src/automation-transfer.ts @@ -0,0 +1,47 @@ +import z from "zod" + +export namespace AutomationTransfer { + export const Item = z + .object({ + name: z.string(), + projects: z.array(z.string()).min(1), + prompt: z.string(), + schedule: z.string().nullable().optional(), + enabled: z.boolean().optional(), + }) + .passthrough() + + export const File = z.object({ + automations: z.array(Item), + }) + + export type Item = z.output + + type Shape = { + name: string + projects: string[] + prompt: string + schedule: string | null + enabled: boolean + } + + export function serialize(items: Shape[]) { + return { + version: 1, + automations: items, + } + } + + export function parse(input: unknown) { + const payload = Array.isArray(input) ? { automations: input } : input + const result = File.safeParse(payload) + if (!result.success) return [] + + return result.data.automations + .map((item) => ({ + ...item, + projects: [...new Set(item.projects.map((project) => project.trim()).filter(Boolean))], + })) + .filter((item) => item.projects.length > 0) + } +} diff --git a/packages/util/src/slugify.ts b/packages/util/src/slugify.ts new file mode 100644 index 00000000000..45787a3662f --- /dev/null +++ b/packages/util/src/slugify.ts @@ -0,0 +1,7 @@ +export function slugify(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") +}