diff --git a/packages/bot-cli/bin/bot.ts b/packages/bot-cli/bin/bot.ts new file mode 100755 index 0000000..7bc2395 --- /dev/null +++ b/packages/bot-cli/bin/bot.ts @@ -0,0 +1,9 @@ +#!/usr/bin/env node + +/** + * CLI entry point + */ + +import { program } from "../src/index.js"; + +program.parse(process.argv); diff --git a/packages/bot-cli/package.json b/packages/bot-cli/package.json new file mode 100644 index 0000000..bc3c0fc --- /dev/null +++ b/packages/bot-cli/package.json @@ -0,0 +1,61 @@ +{ + "name": "@tego/bot-cli", + "version": "0.2.2", + "description": "CLI wrapper for @tego/botjs", + "type": "module", + "bin": { + "bot": "./dist/bin/bot.mjs" + }, + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "exports": { + ".": { + "import": "./dist/src/index.js", + "types": "./dist/src/index.d.ts" + } + }, + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch", + "gen:api": "tsx ./tools/gen-api.ts", + "gen:commands": "tsx ./tools/gen-commands.ts", + "gen": "pnpm gen:api && pnpm gen:commands", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@tego/botjs": "workspace:*", + "commander": "^13.0.0" + }, + "devDependencies": { + "@types/node": "^24.5.2", + "ts-morph": "^27.0.0", + "tsdown": "^0.16.6", + "tsx": "^4.20.6", + "typescript": "^5.9.2", + "vitest": "^4.0.9" + }, + "files": [ + "dist", + "readme.md", + "package.json" + ], + "repository": { + "type": "git", + "url": "https://github.com/tegojs/bot.git", + "directory": "packages/bot-cli" + }, + "homepage": "https://github.com/tegojs/bot#readme", + "bugs": { + "url": "https://github.com/tegojs/bot/issues" + }, + "license": "MIT", + "author": { + "name": "sealday", + "email": "sealday@gmail.com", + "url": "https://github.com/sealday" + }, + "engines": { + "node": ">= 20" + } +} diff --git a/packages/bot-cli/readme.md b/packages/bot-cli/readme.md new file mode 100644 index 0000000..7973c46 --- /dev/null +++ b/packages/bot-cli/readme.md @@ -0,0 +1,53 @@ +# @tego/bot-cli + +CLI wrapper for @tego/botjs. + +## Installation + +```bash +# Install in the workspace +pnpm install + +# Build the package +pnpm --filter @tego/bot-cli build +``` + +## Usage + +### Generic invocation + +```bash +bot call moveMouse --json '[100,200]' +bot call mouseClick --json '["left", true]' +bot call captureScreen --json '[]' --out screenshot.png +``` + +### Args input modes + +```bash +bot call moveMouse --file args.json +bot call moveMouse --stdin +``` + +### Dry-run validation + +```bash +bot call moveMouse --json '[100,200]' --dry-run +``` + +### Doctor command + +```bash +bot doctor +bot doctor --json-output +``` + +## Output + +- Default output is human-readable. +- Use `--json` to emit machine JSON output. +- For Buffer results, use `--out` to write to file. + +## License + +MIT diff --git a/packages/bot-cli/src/commands/call.ts b/packages/bot-cli/src/commands/call.ts new file mode 100644 index 0000000..ba7f7be --- /dev/null +++ b/packages/bot-cli/src/commands/call.ts @@ -0,0 +1,52 @@ +import * as fs from "node:fs/promises"; +import { stdin as stdinStream } from "node:process"; +import { invokeExport } from "./invoke.js"; + +export interface CallOptions { + json?: string; + file?: string; + stdin?: boolean; + out?: string; + jsonOutput?: boolean; + validate?: boolean; + dryRun?: boolean; +} + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stdinStream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); +} + +async function loadArgs(options: CallOptions): Promise { + if (options.json) { + return JSON.parse(options.json); + } + if (options.file) { + const contents = await fs.readFile(options.file, "utf8"); + return JSON.parse(contents); + } + if (options.stdin) { + const contents = await readStdin(); + return JSON.parse(contents); + } + return []; +} + +export async function callCommand( + exportName: string, + options: CallOptions, +): Promise { + const args = await loadArgs(options); + const normalizedArgs = Array.isArray(args) ? args : [args]; + if (options.dryRun) { + await invokeExport(exportName, normalizedArgs, { + ...options, + out: undefined, + }); + return; + } + await invokeExport(exportName, normalizedArgs, options); +} diff --git a/packages/bot-cli/src/commands/doctor.test.ts b/packages/bot-cli/src/commands/doctor.test.ts new file mode 100644 index 0000000..2668c16 --- /dev/null +++ b/packages/bot-cli/src/commands/doctor.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it, vi } from "vitest"; +import { doctorCommand } from "./doctor.js"; + +describe("doctorCommand", () => { + it("prints messages for macOS", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("darwin"); + const writeSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + + await doctorCommand(true); + + const output = writeSpy.mock.calls.map((call) => call[0]).join(""); + expect(output).toContain("Screen Recording"); + platformSpy.mockRestore(); + writeSpy.mockRestore(); + }); +}); diff --git a/packages/bot-cli/src/commands/doctor.ts b/packages/bot-cli/src/commands/doctor.ts new file mode 100644 index 0000000..aa01bbe --- /dev/null +++ b/packages/bot-cli/src/commands/doctor.ts @@ -0,0 +1,40 @@ +import { platform } from "node:os"; + +interface DoctorResult { + ok: boolean; + platform: string; + messages: string[]; +} + +export async function doctorCommand(jsonOutput: boolean): Promise { + const result: DoctorResult = { + ok: true, + platform: platform(), + messages: [], + }; + + if (result.platform === "darwin") { + result.messages.push( + "macOS permissions: ensure Screen Recording access is granted (System Settings > Privacy & Security > Screen Recording).", + ); + } else if (result.platform === "win32") { + result.messages.push( + "Windows: no additional permissions typically required.", + ); + } else if (result.platform === "linux") { + result.messages.push( + "Linux: ensure a display server is available (X11/Wayland).", + ); + } else { + result.messages.push(`Platform '${result.platform}' is untested.`); + } + + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(result)}\n`); + return; + } + + for (const message of result.messages) { + process.stdout.write(`${message}\n`); + } +} diff --git a/packages/bot-cli/src/commands/invoke.ts b/packages/bot-cli/src/commands/invoke.ts new file mode 100644 index 0000000..f79a9a3 --- /dev/null +++ b/packages/bot-cli/src/commands/invoke.ts @@ -0,0 +1,151 @@ +import * as fs from "node:fs/promises"; +import * as bot from "@tego/botjs"; +import { validateArgs } from "./validate.js"; + +export interface InvokeOptions { + out?: string; + jsonOutput?: boolean; + validate?: boolean; + dryRun?: boolean; +} + +function isBuffer(value: unknown): value is Buffer { + return Buffer.isBuffer(value); +} + +function extractBufferField( + value: unknown, +): { buffer: Buffer; remaining?: Record } | null { + if (!value || typeof value !== "object") { + return null; + } + const record = value as Record; + if (isBuffer(record.image)) { + const { image, ...rest } = record; + return { buffer: image, remaining: rest }; + } + if (isBuffer(record.buffer)) { + const { buffer, ...rest } = record; + return { buffer, remaining: rest }; + } + return null; +} + +function isPromise(value: T | Promise): value is Promise { + return Boolean(value) && typeof (value as Promise).then === "function"; +} + +async function writeResult( + result: unknown, + options: InvokeOptions, +): Promise { + if (isBuffer(result)) { + if (!options.out) { + throw new Error("Buffer result requires --out to write file."); + } + await fs.writeFile(options.out, result); + if (options.jsonOutput) { + process.stdout.write( + JSON.stringify({ out: options.out, bytes: result.length }), + ); + } else { + process.stdout.write(`Wrote ${result.length} bytes to ${options.out}\n`); + } + return; + } + + const extracted = extractBufferField(result); + if (options.out) { + if (!extracted) { + throw new Error( + "--out was provided but result did not contain a Buffer.", + ); + } + await fs.writeFile(options.out, extracted.buffer); + if (options.jsonOutput) { + const payload = { + out: options.out, + bytes: extracted.buffer.length, + ...(extracted.remaining && Object.keys(extracted.remaining).length > 0 + ? { result: extracted.remaining } + : {}), + }; + process.stdout.write(`${JSON.stringify(payload)}\n`); + } else { + process.stdout.write( + `Wrote ${extracted.buffer.length} bytes to ${options.out}\n`, + ); + } + return; + } + + if (options.jsonOutput) { + process.stdout.write(`${JSON.stringify(result)}\n`); + return; + } + + if (typeof result === "undefined") { + process.stdout.write("OK\n"); + return; + } + + if (typeof result === "string") { + process.stdout.write(`${result}\n`); + return; + } + + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} + +export async function invokeExport( + exportName: string, + args: unknown[], + options: InvokeOptions, +): Promise { + const fn = (bot as Record)[exportName]; + if (typeof fn !== "function") { + const names = Object.keys(bot).sort(); + const matches = names.filter((name) => + name.toLowerCase().includes(exportName.toLowerCase()), + ); + const suffix = + matches.length > 0 ? `\nClosest matches: ${matches.join(", ")}` : ""; + throw new Error(`Export '${exportName}' is not a function.${suffix}`); + } + + const minArgs = fn.length; + const shouldValidate = options.validate !== false; + await validateArgs(exportName, args, shouldValidate); + if ( + shouldValidate && + args.length === 1 && + args[0] && + typeof args[0] === "object" && + !Array.isArray(args[0]) && + minArgs > 1 + ) { + throw new Error( + `Argument for '${exportName}' should be an array when calling functions with ${minArgs} parameters.`, + ); + } + + if (options.dryRun) { + if (options.jsonOutput) { + process.stdout.write( + `${JSON.stringify({ ok: true, exportName, args, dryRun: true })}\n`, + ); + } else { + process.stdout.write(`OK (dry-run): ${exportName}\n`); + } + return; + } + + let result: unknown; + result = (fn as (...values: unknown[]) => unknown)(...args); + + if (isPromise(result)) { + result = await result; + } + + await writeResult(result, options); +} diff --git a/packages/bot-cli/src/commands/validate.ts b/packages/bot-cli/src/commands/validate.ts new file mode 100644 index 0000000..a82a529 --- /dev/null +++ b/packages/bot-cli/src/commands/validate.ts @@ -0,0 +1,46 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export interface ApiSpec { + exports: Array<{ + name: string; + kind: string; + params?: Array<{ name: string; optional?: boolean }>; + }>; +} + +const scriptDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const apiPath = resolve(scriptDir, "../../botjs/generated/api.json"); + +let cachedSpec: ApiSpec | null = null; + +async function loadApiSpec(): Promise { + if (cachedSpec) return cachedSpec; + const raw = await readFile(apiPath, "utf8"); + cachedSpec = JSON.parse(raw) as ApiSpec; + return cachedSpec; +} + +export async function validateArgs( + exportName: string, + args: unknown[], + validate: boolean, +): Promise { + if (!validate) return; + const spec = await loadApiSpec(); + const entry = spec.exports.find((item) => item.name === exportName); + if (!entry || entry.kind !== "function") return; + const requiredCount = (entry.params ?? []).filter( + (param) => !param.optional, + ).length; + if (args.length < requiredCount) { + throw new Error( + `Expected at least ${requiredCount} argument(s) for '${exportName}', received ${args.length}.`, + ); + } +} + +function dirname(path: string): string { + return path.slice(0, Math.max(0, path.lastIndexOf("/"))); +} diff --git a/packages/bot-cli/src/generated/commands.ts b/packages/bot-cli/src/generated/commands.ts new file mode 100644 index 0000000..6ffccce --- /dev/null +++ b/packages/bot-cli/src/generated/commands.ts @@ -0,0 +1,806 @@ +// Generated file. Do not edit manually. +import type { Command } from "commander"; +import { invokeExport } from "../commands/invoke.js"; + +const BOOL_TRUE = new Set(["true", "1", "yes", "y", "on"]); +const BOOL_FALSE = new Set(["false", "0", "no", "n", "off"]); +function parseArg(value: string): unknown { + try { + return JSON.parse(value); + } catch { + if (/^0x[0-9a-f]+$/i.test(value)) { + return Number.parseInt(value, 16); + } + const lower = value.toLowerCase(); + if (BOOL_TRUE.has(lower)) return true; + if (BOOL_FALSE.has(lower)) return false; + if (/^-?\\d+(?:\\.\\d+)?$/.test(value)) { + return Number(value); + } + return value; + } +} + +export function registerGeneratedCommands(program: Command): void { + program + .command("moveMouse") + .description("Call @tego/botjs moveMouse") + .argument("", "x") + .argument("", "y") + .option("--out ", "Write Buffer result to file") + .option("--json-output", "Emit result as JSON") + .option("--no-validate", "Disable arg shape validation") + .option("--dry-run", "Validate args without invoking") + .action(async (x, y, options) => { + const args = [x, y].map((value) => parseArg(String(value))); + await invokeExport("moveMouse", args, options); + }); + + program + .command("moveMouseSmooth") + .description("Call @tego/botjs moveMouseSmooth") + .argument("", "x") + .argument("", "y") + .argument("", "speed") + .option("--out ", "Write Buffer result to file") + .option("--json-output", "Emit result as JSON") + .option("--no-validate", "Disable arg shape validation") + .option("--dry-run", "Validate args without invoking") + .action(async (x, y, speed, options) => { + const args = [x, y, speed].map((value) => parseArg(String(value))); + await invokeExport("moveMouseSmooth", args, options); + }); + + program + .command("mouseClick") + .description("Call @tego/botjs mouseClick") + .argument("