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/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/util/src/slugify.ts b/packages/util/src/slugify.ts new file mode 100644 index 00000000000..d9116ca6a6d --- /dev/null +++ b/packages/util/src/slugify.ts @@ -0,0 +1,7 @@ +export function slugify(input: string) { + return input + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") +} diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index c504f734fa5..df02daca89b 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -400,6 +400,114 @@ opencode session list --- +### automation + +Manage automations from the CLI. + +```bash +opencode automation [command] +``` + +#### list + +List automations. + +```bash +opencode automation list +``` + +##### Flags + +| Flag | Short | Description | +| ------------- | ----- | ------------------------------------ | +| `--max-count` | `-n` | Limit to N most recent automations | +| `--format` | | Output format: table or json (table) | + +#### create + +Create an automation. + +```bash +opencode automation create --name "Daily summary" --prompt "Summarize changes" --project /path/to/project --schedule "0 9 * * 1-5" +``` + +##### Flags + +| Flag | Description | +| ------------ | -------------------------- | +| `--name` | Automation name | +| `--prompt` | Prompt template | +| `--project` | Project directory (repeat) | +| `--schedule` | Cron schedule (optional) | +| `--enabled` | Enable schedule | + +#### update + +Update an automation. + +```bash +opencode automation update --name "Weekly review" +``` + +#### remove + +Remove an automation. + +```bash +opencode automation remove +``` + +#### run + +Run an automation immediately. + +```bash +opencode automation run +``` + +#### history + +Show run history for an automation. + +```bash +opencode automation history +``` + +#### export + +Export automations to JSON. + +```bash +opencode automation export --all +opencode automation export +``` + +##### Flags + +| Flag | Description | +| ----------- | ------------------------------------------ | +| `--all` | Export all automations | +| `--dir` | Export to a directory | +| `--project` | Export to .opencode/automations in project | + +#### import + +Import automations from JSON. + +```bash +opencode automation import automations.json +opencode automation import --dir .opencode/automations +``` + +##### Flags + +| Flag | Description | +| ----------- | -------------------------------------------- | +| `--dir` | Import all JSON files from a directory | +| `--project` | Import from .opencode/automations in project | + +--- + ### stats Show token usage and cost statistics for your OpenCode sessions. diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 25fe2a1d910..48713217315 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -21,6 +21,17 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf "session_export": "x", "session_new": "n", "session_list": "l", + "automation_list": "o", + "automation_create": "shift+n", + "automation_run": "shift+r", + "automation_open": "shift+o", + "automation_history": "shift+h", + "automation_edit": "shift+e", + "automation_delete": "shift+d", + "automation_clear_history": "shift+l", + "automation_export": "shift+x", + "automation_export_all": "shift+a", + "automation_import": "shift+i", "session_timeline": "g", "session_fork": "none", "session_rename": "none", diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 085eb6169f8..02f6db8ba90 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -65,6 +65,17 @@ When using the OpenCode TUI, you can type `/` followed by a command name to quic Most commands also have keybind using `ctrl+x` as the leader key, where `ctrl+x` is the default leader key. [Learn more](/docs/keybinds). +--- + +## Automations + +The TUI includes an Automations dialog for creating, running, and managing scheduled prompts. + +- Open the command palette and select **Automations**. +- Use the built-in keybinds to create, run, edit, or export automations. + +Automations are stored on the server and synced to the TUI via the global event stream. + Here are all available slash commands: ---