diff --git a/src/api/curl.js b/src/api/curl.js index c9174fc..8b1d64b 100644 --- a/src/api/curl.js +++ b/src/api/curl.js @@ -8,24 +8,11 @@ // It may throw an error if the request fails (curl exits with a non-zero status code). // 4. REQUIREMENT: The `curl` command must be available in the system PATH. -import { exec } from "child_process"; +import { execShell } from "#root/src/api/shell.js"; import { fetchToCurl } from "fetch-to-curl"; export async function* curlRequest(url, options) { const curl_cmd = fetchToCurl(url, options) + " --silent --no-buffer"; - const childProcess = exec(curl_cmd); - - for await (const chunk of childProcess.stdout) { - yield chunk.toString().replace(/\r/g, "\n"); - } - - const exitCode = await new Promise((resolve, reject) => { - childProcess.on("exit", resolve); - childProcess.on("error", reject); - }); - - if (exitCode !== 0) { - throw new Error(`curl exited with code ${exitCode}`); - } + yield* execShell(curl_cmd); } diff --git a/src/api/shell.js b/src/api/shell.js new file mode 100644 index 0000000..31ca27c --- /dev/null +++ b/src/api/shell.js @@ -0,0 +1,38 @@ +/// This is the shell interface, which uses the command-line shell to execute commands. +/// It provides two functions: `execShell` and `execShellNoStream`. +/// `execShell` takes a command, along with other options, and returns an async generator that yields the output. +/// `execShellNoStream` is an async function that executes the command and returns the output as a string. +/// Both functions may throw an error if the command fails, unless `ignoreErrors` is set to true. + +import { exec } from "child_process"; +import { DEFAULT_ENV } from "#root/src/helpers/env.js"; + +export const DEFAULT_SHELL_OPTIONS = { + env: DEFAULT_ENV, +}; + +export async function* execShell(cmd, options = DEFAULT_SHELL_OPTIONS, exec_options = { ignoreErrors: false }) { + const childProcess = exec(cmd, options); + + for await (const chunk of childProcess.stdout) { + yield chunk.toString().replace(/\r/g, "\n"); + } + + const exitCode = await new Promise((resolve, reject) => { + childProcess.on("exit", resolve); + childProcess.on("error", reject); + }); + + if (exitCode !== 0 && !exec_options.ignoreErrors) { + throw new Error(`exited with code ${exitCode}`); + } +} + +export async function execShellNoStream(cmd, options = { env: DEFAULT_ENV }, exec_options = { ignoreErrors: false }) { + let output = ""; + for await (const chunk of execShell(cmd, options, exec_options)) { + output += chunk; + } + output = output.trim(); + return output; +} diff --git a/src/api/tools/ddgs.js b/src/api/tools/ddgs.js index b6a3e08..ec70941 100644 --- a/src/api/tools/ddgs.js +++ b/src/api/tools/ddgs.js @@ -2,8 +2,7 @@ // with the `ddgs` CLI to perform DuckDuckGo searches. // REQUIREMENT: `pip install duckduckgo-search` in order to install the `ddgs` CLI. -import { exec } from "child_process"; -import { DEFAULT_ENV } from "#root/src/helpers/env.js"; +import { DEFAULT_SHELL_OPTIONS, execShellNoStream } from "#root/src/api/shell.js"; import { escapeString } from "#root/src/helpers/helper.js"; import { getSupportPath } from "#root/src/helpers/extension_helper.js"; import fs from "fs"; @@ -16,19 +15,7 @@ export async function ddgsRequest(query, { maxResults = 15 } = {}) { const ddgs_cmd = `ddgs text -k '${query}' -s off -m ${maxResults} -o "ddgs_results.json"`; const cwd = getSupportPath(); - const childProcess = exec(ddgs_cmd, { - env: DEFAULT_ENV, - cwd: cwd, - }); - - const exitCode = await new Promise((resolve, reject) => { - childProcess.on("exit", () => resolve(0)); - childProcess.on("error", (err) => reject(err)); - }); - - if (exitCode !== 0) { - throw new Error(`ddgs exited with code ${exitCode}`); - } + await execShellNoStream(ddgs_cmd, { ...DEFAULT_SHELL_OPTIONS, cwd }); let results = fs.readFileSync(`${cwd}/ddgs_results.json`, "utf8"); results = JSON.parse(results); diff --git a/src/askAboutScreenContent.jsx b/src/askAboutScreenContent.jsx index 423740d..0861079 100644 --- a/src/askAboutScreenContent.jsx +++ b/src/askAboutScreenContent.jsx @@ -1,6 +1,5 @@ import { closeMainWindow, launchCommand, LaunchType } from "@raycast/api"; -import util from "util"; -import { exec } from "child_process"; +import { execShellNoStream } from "#root/src/api/shell.js"; import { getAssetsPath } from "./helpers/extension_helper.js"; import { image_supported_provider_strings } from "./api/providers.js"; @@ -8,10 +7,9 @@ import { image_supported_provider_strings } from "./api/providers.js"; // which means it does not return any UI view, and instead calls askAI to handle the rendering. // This is because the function is async, and async functions are only permitted in no-view commands. export default async function AskAboutScreenContent(props) { - const execPromise = util.promisify(exec); await closeMainWindow(); const path = `${getAssetsPath()}/screenshot.png`; - await execPromise(`/usr/sbin/screencapture "${path}"`); + await execShellNoStream(`/usr/sbin/screencapture "${path}"`); await launchCommand({ name: "askAI", diff --git a/src/helpers/customCommands.jsx b/src/helpers/customCommands.jsx index 7c160ec..d4e9dcc 100644 --- a/src/helpers/customCommands.jsx +++ b/src/helpers/customCommands.jsx @@ -1,7 +1,8 @@ import { Storage } from "../api/storage.js"; -import { Clipboard } from "@raycast/api"; +import { Clipboard, showToast, Toast } from "@raycast/api"; import { getBrowserTab } from "./browser.jsx"; +import { execShellNoStream } from "#root/src/api/shell.js"; export class CustomCommand { constructor({ name = "", prompt = "", id = Date.now().toString(), options = {} }) { @@ -13,8 +14,11 @@ export class CustomCommand { async processPrompt(prompt, query, selected) { this.input = query ? query : selected ? selected : ""; - const regex = /{([^}]*)}/; + + // const regex = /{([^}]*)}/; // left-to-right, legacy matching method + const regex = /\{([^{}]*)}/; // depth-first matching method; inner placeholders are processed first let match; + while ((match = regex.exec(prompt))) { const placeholder = match[1]; const processed_placeholder = await this.process_placeholder(placeholder); @@ -37,61 +41,82 @@ export class CustomCommand { const main = parts[0].trim(); let processed; - switch (main) { - case "input" || "selection": - processed = this.input; - break; - case "clipboard": - processed = (await Clipboard.read()).text; - break; - case "date": - // e.g. 1 June 2022 - processed = new Date().toLocaleDateString([], { year: "numeric", month: "long", day: "numeric" }); - break; - case "time": - // e.g. 6:45 pm - processed = new Date().toLocaleTimeString([], { hour: "numeric", minute: "numeric" }); - break; - case "datetime": - // e.g. 1 June 2022 at 6:45 pm - processed = `${new Date().toLocaleDateString([], { - year: "numeric", - month: "long", - day: "numeric", - })} at ${new Date().toLocaleTimeString([], { hour: "numeric", minute: "numeric" })}`; - break; - case "day": - // e.g. Monday - processed = new Date().toLocaleDateString([], { weekday: "long" }); - break; - case "browser-tab": - processed = await getBrowserTab(); - break; - default: - processed = t; - break; - } - - // modifiers - const modifiers = parts.slice(1).map((x) => x.trim()); - for (const mod of modifiers) { - switch (mod) { - case "uppercase": - processed = processed.toUpperCase(); + try { + switch (main) { + case "input" || "selection": + processed = this.input; + break; + case "clipboard": + processed = (await Clipboard.read()).text; + break; + case "date": + // e.g. 1 June 2022 + processed = new Date().toLocaleDateString([], { year: "numeric", month: "long", day: "numeric" }); + break; + case "time": + // e.g. 6:45 pm + processed = new Date().toLocaleTimeString([], { hour: "numeric", minute: "numeric" }); break; - case "lowercase": - processed = processed.toLowerCase(); + case "datetime": + // e.g. 1 June 2022 at 6:45 pm + processed = `${new Date().toLocaleDateString([], { + year: "numeric", + month: "long", + day: "numeric", + })} at ${new Date().toLocaleTimeString([], { hour: "numeric", minute: "numeric" })}`; break; - case "trim": - processed = processed.trim(); + case "day": + // e.g. Monday + processed = new Date().toLocaleDateString([], { weekday: "long" }); break; - case "percent-encode": - processed = encodeURIComponent(processed); + case "browser-tab": + processed = await getBrowserTab(); break; - case "json-stringify": - processed = JSON.stringify(processed); + case "shell": { + const toast = await showToast(Toast.Style.Animated, "Running shell command"); + + const command = parts.slice(1).join("|").trim(); + processed = await execShellNoStream(command); + console.log(command + "\n" + processed); + + await toast.hide(); break; + } + default: + processed = t; + break; + } + } catch (e) { + console.log(e); + processed = t; + } + + // modifiers are applied after the main processing, except for shell commands + if (["shell"].includes(main)) return processed; + + try { + const modifiers = parts.slice(1).map((x) => x.trim()); + for (const mod of modifiers) { + switch (mod) { + case "uppercase": + processed = processed.toUpperCase(); + break; + case "lowercase": + processed = processed.toLowerCase(); + break; + case "trim": + processed = processed.trim(); + break; + case "percent-encode": + processed = encodeURIComponent(processed); + break; + case "json-stringify": + processed = JSON.stringify(processed); + break; + } } + } catch (e) { + console.log(e); } return processed;