refactor(src): split oversized modules
This commit is contained in:
BIN
src/cli/.DS_Store
vendored
Normal file
BIN
src/cli/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -1,664 +1 @@
|
||||
import type { Command } from "commander";
|
||||
import { resolveBrowserControlUrl } from "../browser/client.js";
|
||||
import {
|
||||
browserAct,
|
||||
browserArmDialog,
|
||||
browserArmFileChooser,
|
||||
browserDownload,
|
||||
browserNavigate,
|
||||
browserWaitForDownload,
|
||||
} from "../browser/client-actions.js";
|
||||
import type { BrowserFormField } from "../browser/client-actions-core.js";
|
||||
import { danger } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
|
||||
async function readFile(path: string): Promise<string> {
|
||||
const fs = await import("node:fs/promises");
|
||||
return await fs.readFile(path, "utf8");
|
||||
}
|
||||
|
||||
async function readFields(opts: {
|
||||
fields?: string;
|
||||
fieldsFile?: string;
|
||||
}): Promise<BrowserFormField[]> {
|
||||
const payload = opts.fieldsFile
|
||||
? await readFile(opts.fieldsFile)
|
||||
: (opts.fields ?? "");
|
||||
if (!payload.trim()) throw new Error("fields are required");
|
||||
const parsed = JSON.parse(payload) as unknown;
|
||||
if (!Array.isArray(parsed)) throw new Error("fields must be an array");
|
||||
return parsed.map((entry, index) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
throw new Error(`fields[${index}] must be an object`);
|
||||
}
|
||||
const rec = entry as Record<string, unknown>;
|
||||
const ref = typeof rec.ref === "string" ? rec.ref.trim() : "";
|
||||
const type = typeof rec.type === "string" ? rec.type.trim() : "";
|
||||
if (!ref || !type) {
|
||||
throw new Error(`fields[${index}] must include ref and type`);
|
||||
}
|
||||
if (
|
||||
typeof rec.value === "string" ||
|
||||
typeof rec.value === "number" ||
|
||||
typeof rec.value === "boolean"
|
||||
) {
|
||||
return { ref, type, value: rec.value };
|
||||
}
|
||||
if (rec.value === undefined || rec.value === null) {
|
||||
return { ref, type };
|
||||
}
|
||||
throw new Error(
|
||||
`fields[${index}].value must be string, number, boolean, or null`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function registerBrowserActionInputCommands(
|
||||
browser: Command,
|
||||
parentOpts: (cmd: Command) => BrowserParentOpts,
|
||||
) {
|
||||
browser
|
||||
.command("navigate")
|
||||
.description("Navigate the current tab to a URL")
|
||||
.argument("<url>", "URL to navigate to")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (url: string, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserNavigate(baseUrl, {
|
||||
url,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`navigated to ${result.url ?? url}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("resize")
|
||||
.description("Resize the viewport")
|
||||
.argument("<width>", "Viewport width", (v: string) => Number(v))
|
||||
.argument("<height>", "Viewport height", (v: string) => Number(v))
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (width: number, height: number, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height)) {
|
||||
defaultRuntime.error(danger("width and height must be numbers"));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
kind: "resize",
|
||||
width,
|
||||
height,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`resized to ${width}x${height}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("click")
|
||||
.description("Click an element by ref from snapshot")
|
||||
.argument("<ref>", "Ref id from snapshot")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.option("--double", "Double click", false)
|
||||
.option("--button <left|right|middle>", "Mouse button to use")
|
||||
.option("--modifiers <list>", "Comma-separated modifiers (Shift,Alt,Meta)")
|
||||
.action(async (ref: string | undefined, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
const refValue = typeof ref === "string" ? ref.trim() : "";
|
||||
if (!refValue) {
|
||||
defaultRuntime.error(danger("ref is required"));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const modifiers = opts.modifiers
|
||||
? String(opts.modifiers)
|
||||
.split(",")
|
||||
.map((v: string) => v.trim())
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
kind: "click",
|
||||
ref: refValue,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
doubleClick: Boolean(opts.double),
|
||||
button: opts.button?.trim() || undefined,
|
||||
modifiers,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
const suffix = result.url ? ` on ${result.url}` : "";
|
||||
defaultRuntime.log(`clicked ref ${refValue}${suffix}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("type")
|
||||
.description("Type into an element by ref from snapshot")
|
||||
.argument("<ref>", "Ref id from snapshot")
|
||||
.argument("<text>", "Text to type")
|
||||
.option("--submit", "Press Enter after typing", false)
|
||||
.option("--slowly", "Type slowly (human-like)", false)
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (ref: string | undefined, text: string, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
const refValue = typeof ref === "string" ? ref.trim() : "";
|
||||
if (!refValue) {
|
||||
defaultRuntime.error(danger("ref is required"));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
kind: "type",
|
||||
ref: refValue,
|
||||
text,
|
||||
submit: Boolean(opts.submit),
|
||||
slowly: Boolean(opts.slowly),
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`typed into ref ${refValue}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("press")
|
||||
.description("Press a key")
|
||||
.argument("<key>", "Key to press (e.g. Enter)")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (key: string, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
kind: "press",
|
||||
key,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`pressed ${key}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("hover")
|
||||
.description("Hover an element by ai ref")
|
||||
.argument("<ref>", "Ref id from snapshot")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (ref: string, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
kind: "hover",
|
||||
ref,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`hovered ref ${ref}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("scrollintoview")
|
||||
.description("Scroll an element into view by ref from snapshot")
|
||||
.argument("<ref>", "Ref id from snapshot")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.option(
|
||||
"--timeout-ms <ms>",
|
||||
"How long to wait for scroll (default: 20000)",
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.action(async (ref: string | undefined, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
const refValue = typeof ref === "string" ? ref.trim() : "";
|
||||
if (!refValue) {
|
||||
defaultRuntime.error(danger("ref is required"));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
kind: "scrollIntoView",
|
||||
ref: refValue,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs)
|
||||
? opts.timeoutMs
|
||||
: undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`scrolled into view: ${refValue}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("drag")
|
||||
.description("Drag from one ref to another")
|
||||
.argument("<startRef>", "Start ref id")
|
||||
.argument("<endRef>", "End ref id")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (startRef: string, endRef: string, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
kind: "drag",
|
||||
startRef,
|
||||
endRef,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`dragged ${startRef} → ${endRef}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("select")
|
||||
.description("Select option(s) in a select element")
|
||||
.argument("<ref>", "Ref id from snapshot")
|
||||
.argument("<values...>", "Option values to select")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (ref: string, values: string[], opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
kind: "select",
|
||||
ref,
|
||||
values,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`selected ${values.join(", ")}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("upload")
|
||||
.description("Arm file upload for the next file chooser")
|
||||
.argument("<paths...>", "File paths to upload")
|
||||
.option("--ref <ref>", "Ref id from snapshot to click after arming")
|
||||
.option("--input-ref <ref>", "Ref id for <input type=file> to set directly")
|
||||
.option("--element <selector>", "CSS selector for <input type=file>")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.option(
|
||||
"--timeout-ms <ms>",
|
||||
"How long to wait for the next file chooser (default: 120000)",
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.action(async (paths: string[], opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserArmFileChooser(baseUrl, {
|
||||
paths,
|
||||
ref: opts.ref?.trim() || undefined,
|
||||
inputRef: opts.inputRef?.trim() || undefined,
|
||||
element: opts.element?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs)
|
||||
? opts.timeoutMs
|
||||
: undefined,
|
||||
profile,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`upload armed for ${paths.length} file(s)`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("waitfordownload")
|
||||
.description("Wait for the next download (and save it)")
|
||||
.argument("[path]", "Save path (default: /tmp/clawdbot/downloads/...)")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.option(
|
||||
"--timeout-ms <ms>",
|
||||
"How long to wait for the next download (default: 120000)",
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.action(async (outPath: string | undefined, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserWaitForDownload(baseUrl, {
|
||||
path: outPath?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs)
|
||||
? opts.timeoutMs
|
||||
: undefined,
|
||||
profile,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`downloaded: ${result.download.path}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("download")
|
||||
.description("Click a ref and save the resulting download")
|
||||
.argument("<ref>", "Ref id from snapshot to click")
|
||||
.argument("<path>", "Save path")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.option(
|
||||
"--timeout-ms <ms>",
|
||||
"How long to wait for the download to start (default: 120000)",
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.action(async (ref: string, outPath: string, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserDownload(baseUrl, {
|
||||
ref,
|
||||
path: outPath,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs)
|
||||
? opts.timeoutMs
|
||||
: undefined,
|
||||
profile,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`downloaded: ${result.download.path}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("fill")
|
||||
.description("Fill a form with JSON field descriptors")
|
||||
.option("--fields <json>", "JSON array of field objects")
|
||||
.option("--fields-file <path>", "Read JSON array from a file")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const fields = await readFields({
|
||||
fields: opts.fields,
|
||||
fieldsFile: opts.fieldsFile,
|
||||
});
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
kind: "fill",
|
||||
fields,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`filled ${fields.length} field(s)`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("dialog")
|
||||
.description("Arm the next modal dialog (alert/confirm/prompt)")
|
||||
.option("--accept", "Accept the dialog", false)
|
||||
.option("--dismiss", "Dismiss the dialog", false)
|
||||
.option("--prompt <text>", "Prompt response text")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.option(
|
||||
"--timeout-ms <ms>",
|
||||
"How long to wait for the next dialog (default: 120000)",
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.action(async (opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
const accept = opts.accept ? true : opts.dismiss ? false : undefined;
|
||||
if (accept === undefined) {
|
||||
defaultRuntime.error(danger("Specify --accept or --dismiss"));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await browserArmDialog(baseUrl, {
|
||||
accept,
|
||||
promptText: opts.prompt?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs)
|
||||
? opts.timeoutMs
|
||||
: undefined,
|
||||
profile,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log("dialog armed");
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("wait")
|
||||
.description("Wait for time, selector, URL, load state, or JS conditions")
|
||||
.argument("[selector]", "CSS selector to wait for (visible)")
|
||||
.option("--time <ms>", "Wait for N milliseconds", (v: string) => Number(v))
|
||||
.option("--text <value>", "Wait for text to appear")
|
||||
.option("--text-gone <value>", "Wait for text to disappear")
|
||||
.option("--url <pattern>", "Wait for URL (supports globs like **/dash)")
|
||||
.option("--load <load|domcontentloaded|networkidle>", "Wait for load state")
|
||||
.option("--fn <js>", "Wait for JS condition (passed to waitForFunction)")
|
||||
.option(
|
||||
"--timeout-ms <ms>",
|
||||
"How long to wait for each condition (default: 20000)",
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (selector: string | undefined, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const sel = selector?.trim() || undefined;
|
||||
const load =
|
||||
opts.load === "load" ||
|
||||
opts.load === "domcontentloaded" ||
|
||||
opts.load === "networkidle"
|
||||
? (opts.load as "load" | "domcontentloaded" | "networkidle")
|
||||
: undefined;
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
kind: "wait",
|
||||
timeMs: Number.isFinite(opts.time) ? opts.time : undefined,
|
||||
text: opts.text?.trim() || undefined,
|
||||
textGone: opts.textGone?.trim() || undefined,
|
||||
selector: sel,
|
||||
url: opts.url?.trim() || undefined,
|
||||
loadState: load,
|
||||
fn: opts.fn?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs)
|
||||
? opts.timeoutMs
|
||||
: undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log("wait complete");
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("evaluate")
|
||||
.description("Evaluate a function against the page or a ref")
|
||||
.option("--fn <code>", "Function source, e.g. (el) => el.textContent")
|
||||
.option("--ref <id>", "Ref from snapshot")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
if (!opts.fn) {
|
||||
defaultRuntime.error(danger("Missing --fn"));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
kind: "evaluate",
|
||||
fn: opts.fn,
|
||||
ref: opts.ref?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(JSON.stringify(result.result ?? null, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
export { registerBrowserActionInputCommands } from "./browser-cli-actions-input/register.js";
|
||||
|
||||
257
src/cli/browser-cli-actions-input/register.element.ts
Normal file
257
src/cli/browser-cli-actions-input/register.element.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import type { Command } from "commander";
|
||||
import { browserAct } from "../../browser/client-actions.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
||||
import { requireRef, resolveBrowserActionContext } from "./shared.js";
|
||||
|
||||
export function registerBrowserElementCommands(
|
||||
browser: Command,
|
||||
parentOpts: (cmd: Command) => BrowserParentOpts,
|
||||
) {
|
||||
browser
|
||||
.command("click")
|
||||
.description("Click an element by ref from snapshot")
|
||||
.argument("<ref>", "Ref id from snapshot")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.option("--double", "Double click", false)
|
||||
.option("--button <left|right|middle>", "Mouse button to use")
|
||||
.option("--modifiers <list>", "Comma-separated modifiers (Shift,Alt,Meta)")
|
||||
.action(async (ref: string | undefined, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(
|
||||
cmd,
|
||||
parentOpts,
|
||||
);
|
||||
const refValue = requireRef(ref);
|
||||
if (!refValue) return;
|
||||
const modifiers = opts.modifiers
|
||||
? String(opts.modifiers)
|
||||
.split(",")
|
||||
.map((v: string) => v.trim())
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
kind: "click",
|
||||
ref: refValue,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
doubleClick: Boolean(opts.double),
|
||||
button: opts.button?.trim() || undefined,
|
||||
modifiers,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
const suffix = result.url ? ` on ${result.url}` : "";
|
||||
defaultRuntime.log(`clicked ref ${refValue}${suffix}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("type")
|
||||
.description("Type into an element by ref from snapshot")
|
||||
.argument("<ref>", "Ref id from snapshot")
|
||||
.argument("<text>", "Text to type")
|
||||
.option("--submit", "Press Enter after typing", false)
|
||||
.option("--slowly", "Type slowly (human-like)", false)
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (ref: string | undefined, text: string, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(
|
||||
cmd,
|
||||
parentOpts,
|
||||
);
|
||||
const refValue = requireRef(ref);
|
||||
if (!refValue) return;
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
kind: "type",
|
||||
ref: refValue,
|
||||
text,
|
||||
submit: Boolean(opts.submit),
|
||||
slowly: Boolean(opts.slowly),
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`typed into ref ${refValue}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("press")
|
||||
.description("Press a key")
|
||||
.argument("<key>", "Key to press (e.g. Enter)")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (key: string, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(
|
||||
cmd,
|
||||
parentOpts,
|
||||
);
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{ kind: "press", key, targetId: opts.targetId?.trim() || undefined },
|
||||
{ profile },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`pressed ${key}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("hover")
|
||||
.description("Hover an element by ai ref")
|
||||
.argument("<ref>", "Ref id from snapshot")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (ref: string, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(
|
||||
cmd,
|
||||
parentOpts,
|
||||
);
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{ kind: "hover", ref, targetId: opts.targetId?.trim() || undefined },
|
||||
{ profile },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`hovered ref ${ref}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("scrollintoview")
|
||||
.description("Scroll an element into view by ref from snapshot")
|
||||
.argument("<ref>", "Ref id from snapshot")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.option(
|
||||
"--timeout-ms <ms>",
|
||||
"How long to wait for scroll (default: 20000)",
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.action(async (ref: string | undefined, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(
|
||||
cmd,
|
||||
parentOpts,
|
||||
);
|
||||
const refValue = requireRef(ref);
|
||||
if (!refValue) return;
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
kind: "scrollIntoView",
|
||||
ref: refValue,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs)
|
||||
? opts.timeoutMs
|
||||
: undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`scrolled into view: ${refValue}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("drag")
|
||||
.description("Drag from one ref to another")
|
||||
.argument("<startRef>", "Start ref id")
|
||||
.argument("<endRef>", "End ref id")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (startRef: string, endRef: string, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(
|
||||
cmd,
|
||||
parentOpts,
|
||||
);
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
kind: "drag",
|
||||
startRef,
|
||||
endRef,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`dragged ${startRef} → ${endRef}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("select")
|
||||
.description("Select option(s) in a select element")
|
||||
.argument("<ref>", "Ref id from snapshot")
|
||||
.argument("<values...>", "Option values to select")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (ref: string, values: string[], opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(
|
||||
cmd,
|
||||
parentOpts,
|
||||
);
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
kind: "select",
|
||||
ref,
|
||||
values,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`selected ${values.join(", ")}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
173
src/cli/browser-cli-actions-input/register.files-downloads.ts
Normal file
173
src/cli/browser-cli-actions-input/register.files-downloads.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
browserArmDialog,
|
||||
browserArmFileChooser,
|
||||
browserDownload,
|
||||
browserWaitForDownload,
|
||||
} from "../../browser/client-actions.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
||||
import { resolveBrowserActionContext } from "./shared.js";
|
||||
|
||||
export function registerBrowserFilesAndDownloadsCommands(
|
||||
browser: Command,
|
||||
parentOpts: (cmd: Command) => BrowserParentOpts,
|
||||
) {
|
||||
browser
|
||||
.command("upload")
|
||||
.description("Arm file upload for the next file chooser")
|
||||
.argument("<paths...>", "File paths to upload")
|
||||
.option("--ref <ref>", "Ref id from snapshot to click after arming")
|
||||
.option("--input-ref <ref>", "Ref id for <input type=file> to set directly")
|
||||
.option("--element <selector>", "CSS selector for <input type=file>")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.option(
|
||||
"--timeout-ms <ms>",
|
||||
"How long to wait for the next file chooser (default: 120000)",
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.action(async (paths: string[], opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(
|
||||
cmd,
|
||||
parentOpts,
|
||||
);
|
||||
try {
|
||||
const result = await browserArmFileChooser(baseUrl, {
|
||||
paths,
|
||||
ref: opts.ref?.trim() || undefined,
|
||||
inputRef: opts.inputRef?.trim() || undefined,
|
||||
element: opts.element?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs)
|
||||
? opts.timeoutMs
|
||||
: undefined,
|
||||
profile,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`upload armed for ${paths.length} file(s)`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("waitfordownload")
|
||||
.description("Wait for the next download (and save it)")
|
||||
.argument("[path]", "Save path (default: /tmp/clawdbot/downloads/...)")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.option(
|
||||
"--timeout-ms <ms>",
|
||||
"How long to wait for the next download (default: 120000)",
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.action(async (outPath: string | undefined, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(
|
||||
cmd,
|
||||
parentOpts,
|
||||
);
|
||||
try {
|
||||
const result = await browserWaitForDownload(baseUrl, {
|
||||
path: outPath?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs)
|
||||
? opts.timeoutMs
|
||||
: undefined,
|
||||
profile,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`downloaded: ${result.download.path}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("download")
|
||||
.description("Click a ref and save the resulting download")
|
||||
.argument("<ref>", "Ref id from snapshot to click")
|
||||
.argument("<path>", "Save path")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.option(
|
||||
"--timeout-ms <ms>",
|
||||
"How long to wait for the download to start (default: 120000)",
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.action(async (ref: string, outPath: string, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(
|
||||
cmd,
|
||||
parentOpts,
|
||||
);
|
||||
try {
|
||||
const result = await browserDownload(baseUrl, {
|
||||
ref,
|
||||
path: outPath,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs)
|
||||
? opts.timeoutMs
|
||||
: undefined,
|
||||
profile,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`downloaded: ${result.download.path}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("dialog")
|
||||
.description("Arm the next modal dialog (alert/confirm/prompt)")
|
||||
.option("--accept", "Accept the dialog", false)
|
||||
.option("--dismiss", "Dismiss the dialog", false)
|
||||
.option("--prompt <text>", "Prompt response text")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.option(
|
||||
"--timeout-ms <ms>",
|
||||
"How long to wait for the next dialog (default: 120000)",
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.action(async (opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(
|
||||
cmd,
|
||||
parentOpts,
|
||||
);
|
||||
const accept = opts.accept ? true : opts.dismiss ? false : undefined;
|
||||
if (accept === undefined) {
|
||||
defaultRuntime.error(danger("Specify --accept or --dismiss"));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await browserArmDialog(baseUrl, {
|
||||
accept,
|
||||
promptText: opts.prompt?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs)
|
||||
? opts.timeoutMs
|
||||
: undefined,
|
||||
profile,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log("dialog armed");
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
143
src/cli/browser-cli-actions-input/register.form-wait-eval.ts
Normal file
143
src/cli/browser-cli-actions-input/register.form-wait-eval.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { Command } from "commander";
|
||||
import { browserAct } from "../../browser/client-actions.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
||||
import { readFields, resolveBrowserActionContext } from "./shared.js";
|
||||
|
||||
export function registerBrowserFormWaitEvalCommands(
|
||||
browser: Command,
|
||||
parentOpts: (cmd: Command) => BrowserParentOpts,
|
||||
) {
|
||||
browser
|
||||
.command("fill")
|
||||
.description("Fill a form with JSON field descriptors")
|
||||
.option("--fields <json>", "JSON array of field objects")
|
||||
.option("--fields-file <path>", "Read JSON array from a file")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(
|
||||
cmd,
|
||||
parentOpts,
|
||||
);
|
||||
try {
|
||||
const fields = await readFields({
|
||||
fields: opts.fields,
|
||||
fieldsFile: opts.fieldsFile,
|
||||
});
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
kind: "fill",
|
||||
fields,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`filled ${fields.length} field(s)`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("wait")
|
||||
.description("Wait for time, selector, URL, load state, or JS conditions")
|
||||
.argument("[selector]", "CSS selector to wait for (visible)")
|
||||
.option("--time <ms>", "Wait for N milliseconds", (v: string) => Number(v))
|
||||
.option("--text <value>", "Wait for text to appear")
|
||||
.option("--text-gone <value>", "Wait for text to disappear")
|
||||
.option("--url <pattern>", "Wait for URL (supports globs like **/dash)")
|
||||
.option("--load <load|domcontentloaded|networkidle>", "Wait for load state")
|
||||
.option("--fn <js>", "Wait for JS condition (passed to waitForFunction)")
|
||||
.option(
|
||||
"--timeout-ms <ms>",
|
||||
"How long to wait for each condition (default: 20000)",
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (selector: string | undefined, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(
|
||||
cmd,
|
||||
parentOpts,
|
||||
);
|
||||
try {
|
||||
const sel = selector?.trim() || undefined;
|
||||
const load =
|
||||
opts.load === "load" ||
|
||||
opts.load === "domcontentloaded" ||
|
||||
opts.load === "networkidle"
|
||||
? (opts.load as "load" | "domcontentloaded" | "networkidle")
|
||||
: undefined;
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
kind: "wait",
|
||||
timeMs: Number.isFinite(opts.time) ? opts.time : undefined,
|
||||
text: opts.text?.trim() || undefined,
|
||||
textGone: opts.textGone?.trim() || undefined,
|
||||
selector: sel,
|
||||
url: opts.url?.trim() || undefined,
|
||||
loadState: load,
|
||||
fn: opts.fn?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs)
|
||||
? opts.timeoutMs
|
||||
: undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log("wait complete");
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("evaluate")
|
||||
.description("Evaluate a function against the page or a ref")
|
||||
.option("--fn <code>", "Function source, e.g. (el) => el.textContent")
|
||||
.option("--ref <id>", "Ref from snapshot")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(
|
||||
cmd,
|
||||
parentOpts,
|
||||
);
|
||||
if (!opts.fn) {
|
||||
defaultRuntime.error(danger("Missing --fn"));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
kind: "evaluate",
|
||||
fn: opts.fn,
|
||||
ref: opts.ref?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(JSON.stringify(result.result ?? null, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
79
src/cli/browser-cli-actions-input/register.navigation.ts
Normal file
79
src/cli/browser-cli-actions-input/register.navigation.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { Command } from "commander";
|
||||
import { browserAct, browserNavigate } from "../../browser/client-actions.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
||||
import { requireRef, resolveBrowserActionContext } from "./shared.js";
|
||||
|
||||
export function registerBrowserNavigationCommands(
|
||||
browser: Command,
|
||||
parentOpts: (cmd: Command) => BrowserParentOpts,
|
||||
) {
|
||||
browser
|
||||
.command("navigate")
|
||||
.description("Navigate the current tab to a URL")
|
||||
.argument("<url>", "URL to navigate to")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (url: string, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(
|
||||
cmd,
|
||||
parentOpts,
|
||||
);
|
||||
try {
|
||||
const result = await browserNavigate(baseUrl, {
|
||||
url,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`navigated to ${result.url ?? url}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("resize")
|
||||
.description("Resize the viewport")
|
||||
.argument("<width>", "Viewport width", (v: string) => Number(v))
|
||||
.argument("<height>", "Viewport height", (v: string) => Number(v))
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (width: number, height: number, opts, cmd) => {
|
||||
const { parent, baseUrl, profile } = resolveBrowserActionContext(
|
||||
cmd,
|
||||
parentOpts,
|
||||
);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height)) {
|
||||
defaultRuntime.error(danger("width and height must be numbers"));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await browserAct(
|
||||
baseUrl,
|
||||
{
|
||||
kind: "resize",
|
||||
width,
|
||||
height,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
},
|
||||
{ profile },
|
||||
);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`resized to ${width}x${height}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Keep `requireRef` reachable; shared utilities are intended for other modules too.
|
||||
void requireRef;
|
||||
}
|
||||
16
src/cli/browser-cli-actions-input/register.ts
Normal file
16
src/cli/browser-cli-actions-input/register.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Command } from "commander";
|
||||
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
||||
import { registerBrowserElementCommands } from "./register.element.js";
|
||||
import { registerBrowserFilesAndDownloadsCommands } from "./register.files-downloads.js";
|
||||
import { registerBrowserFormWaitEvalCommands } from "./register.form-wait-eval.js";
|
||||
import { registerBrowserNavigationCommands } from "./register.navigation.js";
|
||||
|
||||
export function registerBrowserActionInputCommands(
|
||||
browser: Command,
|
||||
parentOpts: (cmd: Command) => BrowserParentOpts,
|
||||
) {
|
||||
registerBrowserNavigationCommands(browser, parentOpts);
|
||||
registerBrowserElementCommands(browser, parentOpts);
|
||||
registerBrowserFilesAndDownloadsCommands(browser, parentOpts);
|
||||
registerBrowserFormWaitEvalCommands(browser, parentOpts);
|
||||
}
|
||||
73
src/cli/browser-cli-actions-input/shared.ts
Normal file
73
src/cli/browser-cli-actions-input/shared.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Command } from "commander";
|
||||
import { resolveBrowserControlUrl } from "../../browser/client.js";
|
||||
import type { BrowserFormField } from "../../browser/client-actions-core.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
||||
|
||||
export type BrowserActionContext = {
|
||||
parent: BrowserParentOpts;
|
||||
baseUrl: string;
|
||||
profile: string | undefined;
|
||||
};
|
||||
|
||||
export function resolveBrowserActionContext(
|
||||
cmd: Command,
|
||||
parentOpts: (cmd: Command) => BrowserParentOpts,
|
||||
): BrowserActionContext {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
return { parent, baseUrl, profile };
|
||||
}
|
||||
|
||||
export function requireRef(ref: string | undefined) {
|
||||
const refValue = typeof ref === "string" ? ref.trim() : "";
|
||||
if (!refValue) {
|
||||
defaultRuntime.error(danger("ref is required"));
|
||||
defaultRuntime.exit(1);
|
||||
return null;
|
||||
}
|
||||
return refValue;
|
||||
}
|
||||
|
||||
async function readFile(path: string): Promise<string> {
|
||||
const fs = await import("node:fs/promises");
|
||||
return await fs.readFile(path, "utf8");
|
||||
}
|
||||
|
||||
export async function readFields(opts: {
|
||||
fields?: string;
|
||||
fieldsFile?: string;
|
||||
}): Promise<BrowserFormField[]> {
|
||||
const payload = opts.fieldsFile
|
||||
? await readFile(opts.fieldsFile)
|
||||
: (opts.fields ?? "");
|
||||
if (!payload.trim()) throw new Error("fields are required");
|
||||
const parsed = JSON.parse(payload) as unknown;
|
||||
if (!Array.isArray(parsed)) throw new Error("fields must be an array");
|
||||
return parsed.map((entry, index) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
throw new Error(`fields[${index}] must be an object`);
|
||||
}
|
||||
const rec = entry as Record<string, unknown>;
|
||||
const ref = typeof rec.ref === "string" ? rec.ref.trim() : "";
|
||||
const type = typeof rec.type === "string" ? rec.type.trim() : "";
|
||||
if (!ref || !type) {
|
||||
throw new Error(`fields[${index}] must include ref and type`);
|
||||
}
|
||||
if (
|
||||
typeof rec.value === "string" ||
|
||||
typeof rec.value === "number" ||
|
||||
typeof rec.value === "boolean"
|
||||
) {
|
||||
return { ref, type, value: rec.value };
|
||||
}
|
||||
if (rec.value === undefined || rec.value === null) {
|
||||
return { ref, type };
|
||||
}
|
||||
throw new Error(
|
||||
`fields[${index}].value must be string, number, boolean, or null`,
|
||||
);
|
||||
});
|
||||
}
|
||||
187
src/cli/browser-cli-state.cookies-storage.ts
Normal file
187
src/cli/browser-cli-state.cookies-storage.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
import { resolveBrowserControlUrl } from "../browser/client.js";
|
||||
import {
|
||||
browserCookies,
|
||||
browserCookiesClear,
|
||||
browserCookiesSet,
|
||||
browserStorageClear,
|
||||
browserStorageGet,
|
||||
browserStorageSet,
|
||||
} from "../browser/client-actions.js";
|
||||
import { danger } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
|
||||
export function registerBrowserCookiesAndStorageCommands(
|
||||
browser: Command,
|
||||
parentOpts: (cmd: Command) => BrowserParentOpts,
|
||||
) {
|
||||
const cookies = browser.command("cookies").description("Read/write cookies");
|
||||
|
||||
cookies
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserCookies(baseUrl, {
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(JSON.stringify(result.cookies ?? [], null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
cookies
|
||||
.command("set")
|
||||
.description("Set a cookie (requires --url or domain+path)")
|
||||
.argument("<name>", "Cookie name")
|
||||
.argument("<value>", "Cookie value")
|
||||
.requiredOption("--url <url>", "Cookie URL scope (recommended)")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (name: string, value: string, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserCookiesSet(baseUrl, {
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
cookie: { name, value, url: opts.url },
|
||||
profile,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`cookie set: ${name}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
cookies
|
||||
.command("clear")
|
||||
.description("Clear all cookies")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserCookiesClear(baseUrl, {
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log("cookies cleared");
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const storage = browser
|
||||
.command("storage")
|
||||
.description("Read/write localStorage/sessionStorage");
|
||||
|
||||
function registerStorageKind(kind: "local" | "session") {
|
||||
const cmd = storage.command(kind).description(`${kind}Storage commands`);
|
||||
|
||||
cmd
|
||||
.command("get")
|
||||
.description(`Get ${kind}Storage (all keys or one key)`)
|
||||
.argument("[key]", "Key (optional)")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (key: string | undefined, opts, cmd2) => {
|
||||
const parent = parentOpts(cmd2);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserStorageGet(baseUrl, {
|
||||
kind,
|
||||
key: key?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(JSON.stringify(result.values ?? {}, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("set")
|
||||
.description(`Set a ${kind}Storage key`)
|
||||
.argument("<key>", "Key")
|
||||
.argument("<value>", "Value")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (key: string, value: string, opts, cmd2) => {
|
||||
const parent = parentOpts(cmd2);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserStorageSet(baseUrl, {
|
||||
kind,
|
||||
key,
|
||||
value,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`${kind}Storage set: ${key}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("clear")
|
||||
.description(`Clear all ${kind}Storage keys`)
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd2) => {
|
||||
const parent = parentOpts(cmd2);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserStorageClear(baseUrl, {
|
||||
kind,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`${kind}Storage cleared`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
registerStorageKind("local");
|
||||
registerStorageKind("session");
|
||||
}
|
||||
@@ -2,9 +2,6 @@ import type { Command } from "commander";
|
||||
|
||||
import { resolveBrowserControlUrl } from "../browser/client.js";
|
||||
import {
|
||||
browserCookies,
|
||||
browserCookiesClear,
|
||||
browserCookiesSet,
|
||||
browserSetDevice,
|
||||
browserSetGeolocation,
|
||||
browserSetHeaders,
|
||||
@@ -13,14 +10,12 @@ import {
|
||||
browserSetMedia,
|
||||
browserSetOffline,
|
||||
browserSetTimezone,
|
||||
browserStorageClear,
|
||||
browserStorageGet,
|
||||
browserStorageSet,
|
||||
} from "../browser/client-actions.js";
|
||||
import { browserAct } from "../browser/client-actions-core.js";
|
||||
import { danger } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
import { registerBrowserCookiesAndStorageCommands } from "./browser-cli-state.cookies-storage.js";
|
||||
|
||||
function parseOnOff(raw: string): boolean | null {
|
||||
const v = raw.trim().toLowerCase();
|
||||
@@ -33,173 +28,7 @@ export function registerBrowserStateCommands(
|
||||
browser: Command,
|
||||
parentOpts: (cmd: Command) => BrowserParentOpts,
|
||||
) {
|
||||
const cookies = browser.command("cookies").description("Read/write cookies");
|
||||
|
||||
cookies
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserCookies(baseUrl, {
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(JSON.stringify(result.cookies ?? [], null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
cookies
|
||||
.command("set")
|
||||
.description("Set a cookie (requires --url or domain+path)")
|
||||
.argument("<name>", "Cookie name")
|
||||
.argument("<value>", "Cookie value")
|
||||
.requiredOption("--url <url>", "Cookie URL scope (recommended)")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (name: string, value: string, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserCookiesSet(baseUrl, {
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
cookie: { name, value, url: opts.url },
|
||||
profile,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`cookie set: ${name}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
cookies
|
||||
.command("clear")
|
||||
.description("Clear all cookies")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserCookiesClear(baseUrl, {
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log("cookies cleared");
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const storage = browser
|
||||
.command("storage")
|
||||
.description("Read/write localStorage/sessionStorage");
|
||||
|
||||
function registerStorageKind(kind: "local" | "session") {
|
||||
const cmd = storage.command(kind).description(`${kind}Storage commands`);
|
||||
|
||||
cmd
|
||||
.command("get")
|
||||
.description(`Get ${kind}Storage (all keys or one key)`)
|
||||
.argument("[key]", "Key (optional)")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (key: string | undefined, opts, cmd2) => {
|
||||
const parent = parentOpts(cmd2);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserStorageGet(baseUrl, {
|
||||
kind,
|
||||
key: key?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(JSON.stringify(result.values ?? {}, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("set")
|
||||
.description(`Set a ${kind}Storage key`)
|
||||
.argument("<key>", "Key")
|
||||
.argument("<value>", "Value")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (key: string, value: string, opts, cmd2) => {
|
||||
const parent = parentOpts(cmd2);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserStorageSet(baseUrl, {
|
||||
kind,
|
||||
key,
|
||||
value,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`${kind}Storage set: ${key}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("clear")
|
||||
.description(`Clear all ${kind}Storage keys`)
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd2) => {
|
||||
const parent = parentOpts(cmd2);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const profile = parent?.browserProfile;
|
||||
try {
|
||||
const result = await browserStorageClear(baseUrl, {
|
||||
kind,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
profile,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`${kind}Storage cleared`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
registerStorageKind("local");
|
||||
registerStorageKind("session");
|
||||
registerBrowserCookiesAndStorageCommands(browser, parentOpts);
|
||||
|
||||
const set = browser
|
||||
.command("set")
|
||||
|
||||
@@ -1,766 +1 @@
|
||||
import type { Command } from "commander";
|
||||
import { CHANNEL_IDS } from "../channels/registry.js";
|
||||
import { parseAbsoluteTimeMs } from "../cron/parse.js";
|
||||
import type { CronJob, CronSchedule } from "../cron/types.js";
|
||||
import { danger } from "../globals.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||
import type { GatewayRpcOpts } from "./gateway-rpc.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js";
|
||||
|
||||
const CRON_CHANNEL_OPTIONS = ["last", ...CHANNEL_IDS].join("|");
|
||||
|
||||
async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) {
|
||||
try {
|
||||
const res = (await callGatewayFromCli("cron.status", opts, {})) as {
|
||||
enabled?: boolean;
|
||||
storePath?: string;
|
||||
};
|
||||
if (res?.enabled === true) return;
|
||||
const store = typeof res?.storePath === "string" ? res.storePath : "";
|
||||
defaultRuntime.error(
|
||||
[
|
||||
"warning: cron scheduler is disabled in the Gateway; jobs are saved but will not run automatically.",
|
||||
"Re-enable with `cron.enabled: true` (or remove `cron.enabled: false`) and restart the Gateway.",
|
||||
store ? `store: ${store}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
);
|
||||
} catch {
|
||||
// Ignore status failures (older gateway, offline, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
function parseDurationMs(input: string): number | null {
|
||||
const raw = input.trim();
|
||||
if (!raw) return null;
|
||||
const match = raw.match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/i);
|
||||
if (!match) return null;
|
||||
const n = Number.parseFloat(match[1] ?? "");
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
const unit = (match[2] ?? "").toLowerCase();
|
||||
const factor =
|
||||
unit === "ms"
|
||||
? 1
|
||||
: unit === "s"
|
||||
? 1000
|
||||
: unit === "m"
|
||||
? 60_000
|
||||
: unit === "h"
|
||||
? 3_600_000
|
||||
: 86_400_000;
|
||||
return Math.floor(n * factor);
|
||||
}
|
||||
|
||||
function parseAtMs(input: string): number | null {
|
||||
const raw = input.trim();
|
||||
if (!raw) return null;
|
||||
const absolute = parseAbsoluteTimeMs(raw);
|
||||
if (absolute) return absolute;
|
||||
const dur = parseDurationMs(raw);
|
||||
if (dur) return Date.now() + dur;
|
||||
return null;
|
||||
}
|
||||
|
||||
const CRON_ID_PAD = 36;
|
||||
const CRON_NAME_PAD = 24;
|
||||
const CRON_SCHEDULE_PAD = 32;
|
||||
const CRON_NEXT_PAD = 10;
|
||||
const CRON_LAST_PAD = 10;
|
||||
const CRON_STATUS_PAD = 9;
|
||||
const CRON_TARGET_PAD = 9;
|
||||
const CRON_AGENT_PAD = 10;
|
||||
|
||||
const pad = (value: string, width: number) => value.padEnd(width);
|
||||
|
||||
const truncate = (value: string, width: number) => {
|
||||
if (value.length <= width) return value;
|
||||
if (width <= 3) return value.slice(0, width);
|
||||
return `${value.slice(0, width - 3)}...`;
|
||||
};
|
||||
|
||||
const formatIsoMinute = (ms: number) => {
|
||||
const d = new Date(ms);
|
||||
if (Number.isNaN(d.getTime())) return "-";
|
||||
const iso = d.toISOString();
|
||||
return `${iso.slice(0, 10)} ${iso.slice(11, 16)}Z`;
|
||||
};
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
if (ms < 60_000) return `${Math.max(1, Math.round(ms / 1000))}s`;
|
||||
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
|
||||
if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
|
||||
return `${Math.round(ms / 86_400_000)}d`;
|
||||
};
|
||||
|
||||
const formatSpan = (ms: number) => {
|
||||
if (ms < 60_000) return "<1m";
|
||||
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
|
||||
if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
|
||||
return `${Math.round(ms / 86_400_000)}d`;
|
||||
};
|
||||
|
||||
const formatRelative = (ms: number | null | undefined, nowMs: number) => {
|
||||
if (!ms) return "-";
|
||||
const delta = ms - nowMs;
|
||||
const label = formatSpan(Math.abs(delta));
|
||||
return delta >= 0 ? `in ${label}` : `${label} ago`;
|
||||
};
|
||||
|
||||
const formatSchedule = (schedule: CronSchedule) => {
|
||||
if (schedule.kind === "at") return `at ${formatIsoMinute(schedule.atMs)}`;
|
||||
if (schedule.kind === "every")
|
||||
return `every ${formatDuration(schedule.everyMs)}`;
|
||||
return schedule.tz
|
||||
? `cron ${schedule.expr} @ ${schedule.tz}`
|
||||
: `cron ${schedule.expr}`;
|
||||
};
|
||||
|
||||
const formatStatus = (job: CronJob) => {
|
||||
if (!job.enabled) return "disabled";
|
||||
if (job.state.runningAtMs) return "running";
|
||||
return job.state.lastStatus ?? "idle";
|
||||
};
|
||||
|
||||
function printCronList(jobs: CronJob[], runtime = defaultRuntime) {
|
||||
if (jobs.length === 0) {
|
||||
runtime.log("No cron jobs.");
|
||||
return;
|
||||
}
|
||||
|
||||
const rich = isRich();
|
||||
const header = [
|
||||
pad("ID", CRON_ID_PAD),
|
||||
pad("Name", CRON_NAME_PAD),
|
||||
pad("Schedule", CRON_SCHEDULE_PAD),
|
||||
pad("Next", CRON_NEXT_PAD),
|
||||
pad("Last", CRON_LAST_PAD),
|
||||
pad("Status", CRON_STATUS_PAD),
|
||||
pad("Target", CRON_TARGET_PAD),
|
||||
pad("Agent", CRON_AGENT_PAD),
|
||||
].join(" ");
|
||||
|
||||
runtime.log(rich ? theme.heading(header) : header);
|
||||
const now = Date.now();
|
||||
|
||||
for (const job of jobs) {
|
||||
const idLabel = pad(job.id, CRON_ID_PAD);
|
||||
const nameLabel = pad(truncate(job.name, CRON_NAME_PAD), CRON_NAME_PAD);
|
||||
const scheduleLabel = pad(
|
||||
truncate(formatSchedule(job.schedule), CRON_SCHEDULE_PAD),
|
||||
CRON_SCHEDULE_PAD,
|
||||
);
|
||||
const nextLabel = pad(
|
||||
job.enabled ? formatRelative(job.state.nextRunAtMs, now) : "-",
|
||||
CRON_NEXT_PAD,
|
||||
);
|
||||
const lastLabel = pad(
|
||||
formatRelative(job.state.lastRunAtMs, now),
|
||||
CRON_LAST_PAD,
|
||||
);
|
||||
const statusRaw = formatStatus(job);
|
||||
const statusLabel = pad(statusRaw, CRON_STATUS_PAD);
|
||||
const targetLabel = pad(job.sessionTarget, CRON_TARGET_PAD);
|
||||
const agentLabel = pad(
|
||||
truncate(job.agentId ?? "default", CRON_AGENT_PAD),
|
||||
CRON_AGENT_PAD,
|
||||
);
|
||||
|
||||
const coloredStatus = (() => {
|
||||
if (statusRaw === "ok") return colorize(rich, theme.success, statusLabel);
|
||||
if (statusRaw === "error")
|
||||
return colorize(rich, theme.error, statusLabel);
|
||||
if (statusRaw === "running")
|
||||
return colorize(rich, theme.warn, statusLabel);
|
||||
if (statusRaw === "skipped")
|
||||
return colorize(rich, theme.muted, statusLabel);
|
||||
return colorize(rich, theme.muted, statusLabel);
|
||||
})();
|
||||
|
||||
const coloredTarget =
|
||||
job.sessionTarget === "isolated"
|
||||
? colorize(rich, theme.accentBright, targetLabel)
|
||||
: colorize(rich, theme.accent, targetLabel);
|
||||
const coloredAgent = job.agentId
|
||||
? colorize(rich, theme.info, agentLabel)
|
||||
: colorize(rich, theme.muted, agentLabel);
|
||||
|
||||
const line = [
|
||||
colorize(rich, theme.accent, idLabel),
|
||||
colorize(rich, theme.info, nameLabel),
|
||||
colorize(rich, theme.info, scheduleLabel),
|
||||
colorize(rich, theme.muted, nextLabel),
|
||||
colorize(rich, theme.muted, lastLabel),
|
||||
coloredStatus,
|
||||
coloredTarget,
|
||||
coloredAgent,
|
||||
].join(" ");
|
||||
|
||||
runtime.log(line.trimEnd());
|
||||
}
|
||||
}
|
||||
|
||||
export function registerCronCli(program: Command) {
|
||||
addGatewayClientOptions(
|
||||
program
|
||||
.command("wake")
|
||||
.description(
|
||||
"Enqueue a system event and optionally trigger an immediate heartbeat",
|
||||
)
|
||||
.requiredOption("--text <text>", "System event text")
|
||||
.option(
|
||||
"--mode <mode>",
|
||||
"Wake mode (now|next-heartbeat)",
|
||||
"next-heartbeat",
|
||||
)
|
||||
.option("--json", "Output JSON", false),
|
||||
).action(async (opts) => {
|
||||
try {
|
||||
const result = await callGatewayFromCli(
|
||||
"wake",
|
||||
opts,
|
||||
{ mode: opts.mode, text: opts.text },
|
||||
{ expectFinal: false },
|
||||
);
|
||||
if (opts.json) defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
else defaultRuntime.log("ok");
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const cron = program
|
||||
.command("cron")
|
||||
.description("Manage cron jobs (via Gateway)")
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.muted("Docs:")} ${formatDocsLink(
|
||||
"/cron-jobs",
|
||||
"docs.clawd.bot/cron-jobs",
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("status")
|
||||
.description("Show cron scheduler status")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const res = await callGatewayFromCli("cron.status", opts, {});
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("list")
|
||||
.description("List cron jobs")
|
||||
.option("--all", "Include disabled jobs", false)
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const res = await callGatewayFromCli("cron.list", opts, {
|
||||
includeDisabled: Boolean(opts.all),
|
||||
});
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
return;
|
||||
}
|
||||
const jobs = (res as { jobs?: CronJob[] } | null)?.jobs ?? [];
|
||||
printCronList(jobs, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("add")
|
||||
.alias("create")
|
||||
.description("Add a cron job")
|
||||
.requiredOption("--name <name>", "Job name")
|
||||
.option("--description <text>", "Optional description")
|
||||
.option("--disabled", "Create job disabled", false)
|
||||
.option(
|
||||
"--delete-after-run",
|
||||
"Delete one-shot job after it succeeds",
|
||||
false,
|
||||
)
|
||||
.option("--agent <id>", "Agent id for this job")
|
||||
.option("--session <target>", "Session target (main|isolated)", "main")
|
||||
.option(
|
||||
"--wake <mode>",
|
||||
"Wake mode (now|next-heartbeat)",
|
||||
"next-heartbeat",
|
||||
)
|
||||
.option("--at <when>", "Run once at time (ISO) or +duration (e.g. 20m)")
|
||||
.option("--every <duration>", "Run every duration (e.g. 10m, 1h)")
|
||||
.option("--cron <expr>", "Cron expression (5-field)")
|
||||
.option("--tz <iana>", "Timezone for cron expressions (IANA)", "")
|
||||
.option("--system-event <text>", "System event payload (main session)")
|
||||
.option("--message <text>", "Agent message payload")
|
||||
.option(
|
||||
"--thinking <level>",
|
||||
"Thinking level for agent jobs (off|minimal|low|medium|high)",
|
||||
)
|
||||
.option(
|
||||
"--model <model>",
|
||||
"Model override for agent jobs (provider/model or alias)",
|
||||
)
|
||||
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
|
||||
.option("--deliver", "Deliver agent output", false)
|
||||
.option(
|
||||
"--channel <channel>",
|
||||
`Delivery channel (${CRON_CHANNEL_OPTIONS})`,
|
||||
"last",
|
||||
)
|
||||
.option(
|
||||
"--to <dest>",
|
||||
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
|
||||
)
|
||||
.option(
|
||||
"--best-effort-deliver",
|
||||
"Do not fail the job if delivery fails",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"--post-prefix <prefix>",
|
||||
"Prefix for summary system event",
|
||||
"Cron",
|
||||
)
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const schedule = (() => {
|
||||
const at = typeof opts.at === "string" ? opts.at : "";
|
||||
const every = typeof opts.every === "string" ? opts.every : "";
|
||||
const cronExpr = typeof opts.cron === "string" ? opts.cron : "";
|
||||
const chosen = [
|
||||
Boolean(at),
|
||||
Boolean(every),
|
||||
Boolean(cronExpr),
|
||||
].filter(Boolean).length;
|
||||
if (chosen !== 1) {
|
||||
throw new Error(
|
||||
"Choose exactly one schedule: --at, --every, or --cron",
|
||||
);
|
||||
}
|
||||
if (at) {
|
||||
const atMs = parseAtMs(at);
|
||||
if (!atMs)
|
||||
throw new Error(
|
||||
"Invalid --at; use ISO time or duration like 20m",
|
||||
);
|
||||
return { kind: "at" as const, atMs };
|
||||
}
|
||||
if (every) {
|
||||
const everyMs = parseDurationMs(every);
|
||||
if (!everyMs)
|
||||
throw new Error("Invalid --every; use e.g. 10m, 1h, 1d");
|
||||
return { kind: "every" as const, everyMs };
|
||||
}
|
||||
return {
|
||||
kind: "cron" as const,
|
||||
expr: cronExpr,
|
||||
tz:
|
||||
typeof opts.tz === "string" && opts.tz.trim()
|
||||
? opts.tz.trim()
|
||||
: undefined,
|
||||
};
|
||||
})();
|
||||
|
||||
const sessionTarget = String(opts.session ?? "main");
|
||||
if (sessionTarget !== "main" && sessionTarget !== "isolated") {
|
||||
throw new Error("--session must be main or isolated");
|
||||
}
|
||||
|
||||
const wakeMode = String(opts.wake ?? "next-heartbeat");
|
||||
if (wakeMode !== "now" && wakeMode !== "next-heartbeat") {
|
||||
throw new Error("--wake must be now or next-heartbeat");
|
||||
}
|
||||
|
||||
const agentId =
|
||||
typeof opts.agent === "string" && opts.agent.trim()
|
||||
? normalizeAgentId(opts.agent)
|
||||
: undefined;
|
||||
|
||||
const payload = (() => {
|
||||
const systemEvent =
|
||||
typeof opts.systemEvent === "string"
|
||||
? opts.systemEvent.trim()
|
||||
: "";
|
||||
const message =
|
||||
typeof opts.message === "string" ? opts.message.trim() : "";
|
||||
const chosen = [Boolean(systemEvent), Boolean(message)].filter(
|
||||
Boolean,
|
||||
).length;
|
||||
if (chosen !== 1) {
|
||||
throw new Error(
|
||||
"Choose exactly one payload: --system-event or --message",
|
||||
);
|
||||
}
|
||||
if (systemEvent)
|
||||
return { kind: "systemEvent" as const, text: systemEvent };
|
||||
const timeoutSeconds = opts.timeoutSeconds
|
||||
? Number.parseInt(String(opts.timeoutSeconds), 10)
|
||||
: undefined;
|
||||
return {
|
||||
kind: "agentTurn" as const,
|
||||
message,
|
||||
model:
|
||||
typeof opts.model === "string" && opts.model.trim()
|
||||
? opts.model.trim()
|
||||
: undefined,
|
||||
thinking:
|
||||
typeof opts.thinking === "string" && opts.thinking.trim()
|
||||
? opts.thinking.trim()
|
||||
: undefined,
|
||||
timeoutSeconds:
|
||||
timeoutSeconds && Number.isFinite(timeoutSeconds)
|
||||
? timeoutSeconds
|
||||
: undefined,
|
||||
deliver: Boolean(opts.deliver),
|
||||
channel: typeof opts.channel === "string" ? opts.channel : "last",
|
||||
to:
|
||||
typeof opts.to === "string" && opts.to.trim()
|
||||
? opts.to.trim()
|
||||
: undefined,
|
||||
bestEffortDeliver: Boolean(opts.bestEffortDeliver),
|
||||
};
|
||||
})();
|
||||
|
||||
if (sessionTarget === "main" && payload.kind !== "systemEvent") {
|
||||
throw new Error("Main jobs require --system-event (systemEvent).");
|
||||
}
|
||||
if (sessionTarget === "isolated" && payload.kind !== "agentTurn") {
|
||||
throw new Error("Isolated jobs require --message (agentTurn).");
|
||||
}
|
||||
|
||||
const isolation =
|
||||
sessionTarget === "isolated"
|
||||
? {
|
||||
postToMainPrefix:
|
||||
typeof opts.postPrefix === "string" &&
|
||||
opts.postPrefix.trim()
|
||||
? opts.postPrefix.trim()
|
||||
: "Cron",
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const name = String(opts.name ?? "").trim();
|
||||
if (!name) throw new Error("--name is required");
|
||||
|
||||
const description =
|
||||
typeof opts.description === "string" && opts.description.trim()
|
||||
? opts.description.trim()
|
||||
: undefined;
|
||||
|
||||
const params = {
|
||||
name,
|
||||
description,
|
||||
enabled: !opts.disabled,
|
||||
deleteAfterRun: Boolean(opts.deleteAfterRun),
|
||||
agentId,
|
||||
schedule,
|
||||
sessionTarget,
|
||||
wakeMode,
|
||||
payload,
|
||||
isolation,
|
||||
};
|
||||
|
||||
const res = await callGatewayFromCli("cron.add", opts, params);
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
await warnIfCronSchedulerDisabled(opts);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("rm")
|
||||
.alias("remove")
|
||||
.alias("delete")
|
||||
.description("Remove a cron job")
|
||||
.argument("<id>", "Job id")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (id, opts) => {
|
||||
try {
|
||||
const res = await callGatewayFromCli("cron.remove", opts, { id });
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("enable")
|
||||
.description("Enable a cron job")
|
||||
.argument("<id>", "Job id")
|
||||
.action(async (id, opts) => {
|
||||
try {
|
||||
const res = await callGatewayFromCli("cron.update", opts, {
|
||||
id,
|
||||
patch: { enabled: true },
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
await warnIfCronSchedulerDisabled(opts);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("disable")
|
||||
.description("Disable a cron job")
|
||||
.argument("<id>", "Job id")
|
||||
.action(async (id, opts) => {
|
||||
try {
|
||||
const res = await callGatewayFromCli("cron.update", opts, {
|
||||
id,
|
||||
patch: { enabled: false },
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
await warnIfCronSchedulerDisabled(opts);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("runs")
|
||||
.description("Show cron run history (JSONL-backed)")
|
||||
.requiredOption("--id <id>", "Job id")
|
||||
.option("--limit <n>", "Max entries (default 50)", "50")
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const limitRaw = Number.parseInt(String(opts.limit ?? "50"), 10);
|
||||
const limit =
|
||||
Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 50;
|
||||
const id = String(opts.id);
|
||||
const res = await callGatewayFromCli("cron.runs", opts, {
|
||||
id,
|
||||
limit,
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("edit")
|
||||
.description("Edit a cron job (patch fields)")
|
||||
.argument("<id>", "Job id")
|
||||
.option("--name <name>", "Set name")
|
||||
.option("--description <text>", "Set description")
|
||||
.option("--enable", "Enable job", false)
|
||||
.option("--disable", "Disable job", false)
|
||||
.option(
|
||||
"--delete-after-run",
|
||||
"Delete one-shot job after it succeeds",
|
||||
false,
|
||||
)
|
||||
.option("--keep-after-run", "Keep one-shot job after it succeeds", false)
|
||||
.option("--session <target>", "Session target (main|isolated)")
|
||||
.option("--agent <id>", "Set agent id")
|
||||
.option("--clear-agent", "Unset agent and use default", false)
|
||||
.option("--wake <mode>", "Wake mode (now|next-heartbeat)")
|
||||
.option("--at <when>", "Set one-shot time (ISO) or duration like 20m")
|
||||
.option("--every <duration>", "Set interval duration like 10m")
|
||||
.option("--cron <expr>", "Set cron expression")
|
||||
.option("--tz <iana>", "Timezone for cron expressions (IANA)")
|
||||
.option("--system-event <text>", "Set systemEvent payload")
|
||||
.option("--message <text>", "Set agentTurn payload message")
|
||||
.option("--thinking <level>", "Thinking level for agent jobs")
|
||||
.option("--model <model>", "Model override for agent jobs")
|
||||
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
|
||||
.option("--deliver", "Deliver agent output", false)
|
||||
.option(
|
||||
"--channel <channel>",
|
||||
`Delivery channel (${CRON_CHANNEL_OPTIONS})`,
|
||||
)
|
||||
.option(
|
||||
"--to <dest>",
|
||||
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
|
||||
)
|
||||
.option(
|
||||
"--best-effort-deliver",
|
||||
"Do not fail job if delivery fails",
|
||||
false,
|
||||
)
|
||||
.option("--post-prefix <prefix>", "Prefix for summary system event")
|
||||
.action(async (id, opts) => {
|
||||
try {
|
||||
if (opts.session === "main" && opts.message) {
|
||||
throw new Error(
|
||||
"Main jobs cannot use --message; use --system-event or --session isolated.",
|
||||
);
|
||||
}
|
||||
if (opts.session === "isolated" && opts.systemEvent) {
|
||||
throw new Error(
|
||||
"Isolated jobs cannot use --system-event; use --message or --session main.",
|
||||
);
|
||||
}
|
||||
if (opts.session === "main" && typeof opts.postPrefix === "string") {
|
||||
throw new Error("--post-prefix only applies to isolated jobs.");
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (typeof opts.name === "string") patch.name = opts.name;
|
||||
if (typeof opts.description === "string")
|
||||
patch.description = opts.description;
|
||||
if (opts.enable && opts.disable)
|
||||
throw new Error("Choose --enable or --disable, not both");
|
||||
if (opts.enable) patch.enabled = true;
|
||||
if (opts.disable) patch.enabled = false;
|
||||
if (opts.deleteAfterRun && opts.keepAfterRun) {
|
||||
throw new Error(
|
||||
"Choose --delete-after-run or --keep-after-run, not both",
|
||||
);
|
||||
}
|
||||
if (opts.deleteAfterRun) patch.deleteAfterRun = true;
|
||||
if (opts.keepAfterRun) patch.deleteAfterRun = false;
|
||||
if (typeof opts.session === "string")
|
||||
patch.sessionTarget = opts.session;
|
||||
if (typeof opts.wake === "string") patch.wakeMode = opts.wake;
|
||||
if (opts.agent && opts.clearAgent) {
|
||||
throw new Error("Use --agent or --clear-agent, not both");
|
||||
}
|
||||
if (typeof opts.agent === "string" && opts.agent.trim()) {
|
||||
patch.agentId = normalizeAgentId(opts.agent);
|
||||
}
|
||||
if (opts.clearAgent) {
|
||||
patch.agentId = null;
|
||||
}
|
||||
|
||||
const scheduleChosen = [opts.at, opts.every, opts.cron].filter(
|
||||
Boolean,
|
||||
).length;
|
||||
if (scheduleChosen > 1)
|
||||
throw new Error("Choose at most one schedule change");
|
||||
if (opts.at) {
|
||||
const atMs = parseAtMs(String(opts.at));
|
||||
if (!atMs) throw new Error("Invalid --at");
|
||||
patch.schedule = { kind: "at", atMs };
|
||||
} else if (opts.every) {
|
||||
const everyMs = parseDurationMs(String(opts.every));
|
||||
if (!everyMs) throw new Error("Invalid --every");
|
||||
patch.schedule = { kind: "every", everyMs };
|
||||
} else if (opts.cron) {
|
||||
patch.schedule = {
|
||||
kind: "cron",
|
||||
expr: String(opts.cron),
|
||||
tz:
|
||||
typeof opts.tz === "string" && opts.tz.trim()
|
||||
? opts.tz.trim()
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const payloadChosen = [opts.systemEvent, opts.message].filter(
|
||||
Boolean,
|
||||
).length;
|
||||
if (payloadChosen > 1)
|
||||
throw new Error("Choose at most one payload change");
|
||||
if (opts.systemEvent) {
|
||||
patch.payload = {
|
||||
kind: "systemEvent",
|
||||
text: String(opts.systemEvent),
|
||||
};
|
||||
} else if (opts.message) {
|
||||
const model =
|
||||
typeof opts.model === "string" && opts.model.trim()
|
||||
? opts.model.trim()
|
||||
: undefined;
|
||||
const thinking =
|
||||
typeof opts.thinking === "string" && opts.thinking.trim()
|
||||
? opts.thinking.trim()
|
||||
: undefined;
|
||||
const timeoutSeconds = opts.timeoutSeconds
|
||||
? Number.parseInt(String(opts.timeoutSeconds), 10)
|
||||
: undefined;
|
||||
patch.payload = {
|
||||
kind: "agentTurn",
|
||||
message: String(opts.message),
|
||||
model,
|
||||
thinking,
|
||||
timeoutSeconds:
|
||||
timeoutSeconds && Number.isFinite(timeoutSeconds)
|
||||
? timeoutSeconds
|
||||
: undefined,
|
||||
deliver: Boolean(opts.deliver),
|
||||
channel:
|
||||
typeof opts.channel === "string" ? opts.channel : undefined,
|
||||
to: typeof opts.to === "string" ? opts.to : undefined,
|
||||
bestEffortDeliver: Boolean(opts.bestEffortDeliver),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof opts.postPrefix === "string") {
|
||||
patch.isolation = {
|
||||
postToMainPrefix: opts.postPrefix.trim()
|
||||
? opts.postPrefix
|
||||
: "Cron",
|
||||
};
|
||||
}
|
||||
|
||||
const res = await callGatewayFromCli("cron.update", opts, {
|
||||
id,
|
||||
patch,
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
await warnIfCronSchedulerDisabled(opts);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("run")
|
||||
.description("Run a cron job now (debug)")
|
||||
.argument("<id>", "Job id")
|
||||
.option("--force", "Run even if not due", false)
|
||||
.action(async (id, opts) => {
|
||||
try {
|
||||
const res = await callGatewayFromCli("cron.run", opts, {
|
||||
id,
|
||||
mode: opts.force ? "force" : "due",
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
export { registerCronCli } from "./cron-cli/register.js";
|
||||
|
||||
271
src/cli/cron-cli/register.cron-add.ts
Normal file
271
src/cli/cron-cli/register.cron-add.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import type { Command } from "commander";
|
||||
import type { CronJob } from "../../cron/types.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { GatewayRpcOpts } from "../gateway-rpc.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
import { parsePositiveIntOrUndefined } from "../program/helpers.js";
|
||||
import {
|
||||
CRON_CHANNEL_OPTIONS,
|
||||
parseAtMs,
|
||||
parseDurationMs,
|
||||
printCronList,
|
||||
warnIfCronSchedulerDisabled,
|
||||
} from "./shared.js";
|
||||
|
||||
export function registerCronStatusCommand(cron: Command) {
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("status")
|
||||
.description("Show cron scheduler status")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const res = await callGatewayFromCli("cron.status", opts, {});
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function registerCronListCommand(cron: Command) {
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("list")
|
||||
.description("List cron jobs")
|
||||
.option("--all", "Include disabled jobs", false)
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const res = await callGatewayFromCli("cron.list", opts, {
|
||||
includeDisabled: Boolean(opts.all),
|
||||
});
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
return;
|
||||
}
|
||||
const jobs = (res as { jobs?: CronJob[] } | null)?.jobs ?? [];
|
||||
printCronList(jobs, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function registerCronAddCommand(cron: Command) {
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("add")
|
||||
.alias("create")
|
||||
.description("Add a cron job")
|
||||
.requiredOption("--name <name>", "Job name")
|
||||
.option("--description <text>", "Optional description")
|
||||
.option("--disabled", "Create job disabled", false)
|
||||
.option(
|
||||
"--delete-after-run",
|
||||
"Delete one-shot job after it succeeds",
|
||||
false,
|
||||
)
|
||||
.option("--agent <id>", "Agent id for this job")
|
||||
.option("--session <target>", "Session target (main|isolated)", "main")
|
||||
.option(
|
||||
"--wake <mode>",
|
||||
"Wake mode (now|next-heartbeat)",
|
||||
"next-heartbeat",
|
||||
)
|
||||
.option("--at <when>", "Run once at time (ISO) or +duration (e.g. 20m)")
|
||||
.option("--every <duration>", "Run every duration (e.g. 10m, 1h)")
|
||||
.option("--cron <expr>", "Cron expression (5-field)")
|
||||
.option("--tz <iana>", "Timezone for cron expressions (IANA)", "")
|
||||
.option("--system-event <text>", "System event payload (main session)")
|
||||
.option("--message <text>", "Agent message payload")
|
||||
.option(
|
||||
"--thinking <level>",
|
||||
"Thinking level for agent jobs (off|minimal|low|medium|high)",
|
||||
)
|
||||
.option(
|
||||
"--model <model>",
|
||||
"Model override for agent jobs (provider/model or alias)",
|
||||
)
|
||||
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
|
||||
.option("--deliver", "Deliver agent output", false)
|
||||
.option(
|
||||
"--channel <channel>",
|
||||
`Delivery channel (${CRON_CHANNEL_OPTIONS})`,
|
||||
"last",
|
||||
)
|
||||
.option(
|
||||
"--to <dest>",
|
||||
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
|
||||
)
|
||||
.option(
|
||||
"--best-effort-deliver",
|
||||
"Do not fail the job if delivery fails",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"--post-prefix <prefix>",
|
||||
"Prefix for summary system event",
|
||||
"Cron",
|
||||
)
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts: GatewayRpcOpts & Record<string, unknown>) => {
|
||||
try {
|
||||
const schedule = (() => {
|
||||
const at = typeof opts.at === "string" ? opts.at : "";
|
||||
const every = typeof opts.every === "string" ? opts.every : "";
|
||||
const cronExpr = typeof opts.cron === "string" ? opts.cron : "";
|
||||
const chosen = [
|
||||
Boolean(at),
|
||||
Boolean(every),
|
||||
Boolean(cronExpr),
|
||||
].filter(Boolean).length;
|
||||
if (chosen !== 1) {
|
||||
throw new Error(
|
||||
"Choose exactly one schedule: --at, --every, or --cron",
|
||||
);
|
||||
}
|
||||
if (at) {
|
||||
const atMs = parseAtMs(at);
|
||||
if (!atMs)
|
||||
throw new Error(
|
||||
"Invalid --at; use ISO time or duration like 20m",
|
||||
);
|
||||
return { kind: "at" as const, atMs };
|
||||
}
|
||||
if (every) {
|
||||
const everyMs = parseDurationMs(every);
|
||||
if (!everyMs)
|
||||
throw new Error("Invalid --every; use e.g. 10m, 1h, 1d");
|
||||
return { kind: "every" as const, everyMs };
|
||||
}
|
||||
return {
|
||||
kind: "cron" as const,
|
||||
expr: cronExpr,
|
||||
tz:
|
||||
typeof opts.tz === "string" && opts.tz.trim()
|
||||
? opts.tz.trim()
|
||||
: undefined,
|
||||
};
|
||||
})();
|
||||
|
||||
const sessionTargetRaw =
|
||||
typeof opts.session === "string" ? opts.session : "main";
|
||||
const sessionTarget = sessionTargetRaw.trim() || "main";
|
||||
if (sessionTarget !== "main" && sessionTarget !== "isolated") {
|
||||
throw new Error("--session must be main or isolated");
|
||||
}
|
||||
|
||||
const wakeModeRaw =
|
||||
typeof opts.wake === "string" ? opts.wake : "next-heartbeat";
|
||||
const wakeMode = wakeModeRaw.trim() || "next-heartbeat";
|
||||
if (wakeMode !== "now" && wakeMode !== "next-heartbeat") {
|
||||
throw new Error("--wake must be now or next-heartbeat");
|
||||
}
|
||||
|
||||
const agentId =
|
||||
typeof opts.agent === "string" && opts.agent.trim()
|
||||
? normalizeAgentId(opts.agent)
|
||||
: undefined;
|
||||
|
||||
const payload = (() => {
|
||||
const systemEvent =
|
||||
typeof opts.systemEvent === "string"
|
||||
? opts.systemEvent.trim()
|
||||
: "";
|
||||
const message =
|
||||
typeof opts.message === "string" ? opts.message.trim() : "";
|
||||
const chosen = [Boolean(systemEvent), Boolean(message)].filter(
|
||||
Boolean,
|
||||
).length;
|
||||
if (chosen !== 1) {
|
||||
throw new Error(
|
||||
"Choose exactly one payload: --system-event or --message",
|
||||
);
|
||||
}
|
||||
if (systemEvent)
|
||||
return { kind: "systemEvent" as const, text: systemEvent };
|
||||
const timeoutSeconds = parsePositiveIntOrUndefined(
|
||||
opts.timeoutSeconds,
|
||||
);
|
||||
return {
|
||||
kind: "agentTurn" as const,
|
||||
message,
|
||||
model:
|
||||
typeof opts.model === "string" && opts.model.trim()
|
||||
? opts.model.trim()
|
||||
: undefined,
|
||||
thinking:
|
||||
typeof opts.thinking === "string" && opts.thinking.trim()
|
||||
? opts.thinking.trim()
|
||||
: undefined,
|
||||
timeoutSeconds:
|
||||
timeoutSeconds && Number.isFinite(timeoutSeconds)
|
||||
? timeoutSeconds
|
||||
: undefined,
|
||||
deliver: Boolean(opts.deliver),
|
||||
channel: typeof opts.channel === "string" ? opts.channel : "last",
|
||||
to:
|
||||
typeof opts.to === "string" && opts.to.trim()
|
||||
? opts.to.trim()
|
||||
: undefined,
|
||||
bestEffortDeliver: Boolean(opts.bestEffortDeliver),
|
||||
};
|
||||
})();
|
||||
|
||||
if (sessionTarget === "main" && payload.kind !== "systemEvent") {
|
||||
throw new Error("Main jobs require --system-event (systemEvent).");
|
||||
}
|
||||
if (sessionTarget === "isolated" && payload.kind !== "agentTurn") {
|
||||
throw new Error("Isolated jobs require --message (agentTurn).");
|
||||
}
|
||||
|
||||
const isolation =
|
||||
sessionTarget === "isolated"
|
||||
? {
|
||||
postToMainPrefix:
|
||||
typeof opts.postPrefix === "string" &&
|
||||
opts.postPrefix.trim()
|
||||
? opts.postPrefix.trim()
|
||||
: "Cron",
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const nameRaw = typeof opts.name === "string" ? opts.name : "";
|
||||
const name = nameRaw.trim();
|
||||
if (!name) throw new Error("--name is required");
|
||||
|
||||
const description =
|
||||
typeof opts.description === "string" && opts.description.trim()
|
||||
? opts.description.trim()
|
||||
: undefined;
|
||||
|
||||
const params = {
|
||||
name,
|
||||
description,
|
||||
enabled: !opts.disabled,
|
||||
deleteAfterRun: Boolean(opts.deleteAfterRun),
|
||||
agentId,
|
||||
schedule,
|
||||
sessionTarget,
|
||||
wakeMode,
|
||||
payload,
|
||||
isolation,
|
||||
};
|
||||
|
||||
const res = await callGatewayFromCli("cron.add", opts, params);
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
await warnIfCronSchedulerDisabled(opts);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
184
src/cli/cron-cli/register.cron-edit.ts
Normal file
184
src/cli/cron-cli/register.cron-edit.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { Command } from "commander";
|
||||
import { danger } from "../../globals.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
import {
|
||||
CRON_CHANNEL_OPTIONS,
|
||||
parseAtMs,
|
||||
parseDurationMs,
|
||||
warnIfCronSchedulerDisabled,
|
||||
} from "./shared.js";
|
||||
|
||||
export function registerCronEditCommand(cron: Command) {
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("edit")
|
||||
.description("Edit a cron job (patch fields)")
|
||||
.argument("<id>", "Job id")
|
||||
.option("--name <name>", "Set name")
|
||||
.option("--description <text>", "Set description")
|
||||
.option("--enable", "Enable job", false)
|
||||
.option("--disable", "Disable job", false)
|
||||
.option(
|
||||
"--delete-after-run",
|
||||
"Delete one-shot job after it succeeds",
|
||||
false,
|
||||
)
|
||||
.option("--keep-after-run", "Keep one-shot job after it succeeds", false)
|
||||
.option("--session <target>", "Session target (main|isolated)")
|
||||
.option("--agent <id>", "Set agent id")
|
||||
.option("--clear-agent", "Unset agent and use default", false)
|
||||
.option("--wake <mode>", "Wake mode (now|next-heartbeat)")
|
||||
.option("--at <when>", "Set one-shot time (ISO) or duration like 20m")
|
||||
.option("--every <duration>", "Set interval duration like 10m")
|
||||
.option("--cron <expr>", "Set cron expression")
|
||||
.option("--tz <iana>", "Timezone for cron expressions (IANA)")
|
||||
.option("--system-event <text>", "Set systemEvent payload")
|
||||
.option("--message <text>", "Set agentTurn payload message")
|
||||
.option("--thinking <level>", "Thinking level for agent jobs")
|
||||
.option("--model <model>", "Model override for agent jobs")
|
||||
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
|
||||
.option("--deliver", "Deliver agent output", false)
|
||||
.option(
|
||||
"--channel <channel>",
|
||||
`Delivery channel (${CRON_CHANNEL_OPTIONS})`,
|
||||
)
|
||||
.option(
|
||||
"--to <dest>",
|
||||
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
|
||||
)
|
||||
.option(
|
||||
"--best-effort-deliver",
|
||||
"Do not fail job if delivery fails",
|
||||
false,
|
||||
)
|
||||
.option("--post-prefix <prefix>", "Prefix for summary system event")
|
||||
.action(async (id, opts) => {
|
||||
try {
|
||||
if (opts.session === "main" && opts.message) {
|
||||
throw new Error(
|
||||
"Main jobs cannot use --message; use --system-event or --session isolated.",
|
||||
);
|
||||
}
|
||||
if (opts.session === "isolated" && opts.systemEvent) {
|
||||
throw new Error(
|
||||
"Isolated jobs cannot use --system-event; use --message or --session main.",
|
||||
);
|
||||
}
|
||||
if (opts.session === "main" && typeof opts.postPrefix === "string") {
|
||||
throw new Error("--post-prefix only applies to isolated jobs.");
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (typeof opts.name === "string") patch.name = opts.name;
|
||||
if (typeof opts.description === "string")
|
||||
patch.description = opts.description;
|
||||
if (opts.enable && opts.disable)
|
||||
throw new Error("Choose --enable or --disable, not both");
|
||||
if (opts.enable) patch.enabled = true;
|
||||
if (opts.disable) patch.enabled = false;
|
||||
if (opts.deleteAfterRun && opts.keepAfterRun) {
|
||||
throw new Error(
|
||||
"Choose --delete-after-run or --keep-after-run, not both",
|
||||
);
|
||||
}
|
||||
if (opts.deleteAfterRun) patch.deleteAfterRun = true;
|
||||
if (opts.keepAfterRun) patch.deleteAfterRun = false;
|
||||
if (typeof opts.session === "string")
|
||||
patch.sessionTarget = opts.session;
|
||||
if (typeof opts.wake === "string") patch.wakeMode = opts.wake;
|
||||
if (opts.agent && opts.clearAgent) {
|
||||
throw new Error("Use --agent or --clear-agent, not both");
|
||||
}
|
||||
if (typeof opts.agent === "string" && opts.agent.trim()) {
|
||||
patch.agentId = normalizeAgentId(opts.agent);
|
||||
}
|
||||
if (opts.clearAgent) {
|
||||
patch.agentId = null;
|
||||
}
|
||||
|
||||
const scheduleChosen = [opts.at, opts.every, opts.cron].filter(
|
||||
Boolean,
|
||||
).length;
|
||||
if (scheduleChosen > 1)
|
||||
throw new Error("Choose at most one schedule change");
|
||||
if (opts.at) {
|
||||
const atMs = parseAtMs(String(opts.at));
|
||||
if (!atMs) throw new Error("Invalid --at");
|
||||
patch.schedule = { kind: "at", atMs };
|
||||
} else if (opts.every) {
|
||||
const everyMs = parseDurationMs(String(opts.every));
|
||||
if (!everyMs) throw new Error("Invalid --every");
|
||||
patch.schedule = { kind: "every", everyMs };
|
||||
} else if (opts.cron) {
|
||||
patch.schedule = {
|
||||
kind: "cron",
|
||||
expr: String(opts.cron),
|
||||
tz:
|
||||
typeof opts.tz === "string" && opts.tz.trim()
|
||||
? opts.tz.trim()
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const payloadChosen = [opts.systemEvent, opts.message].filter(
|
||||
Boolean,
|
||||
).length;
|
||||
if (payloadChosen > 1)
|
||||
throw new Error("Choose at most one payload change");
|
||||
if (opts.systemEvent) {
|
||||
patch.payload = {
|
||||
kind: "systemEvent",
|
||||
text: String(opts.systemEvent),
|
||||
};
|
||||
} else if (opts.message) {
|
||||
const model =
|
||||
typeof opts.model === "string" && opts.model.trim()
|
||||
? opts.model.trim()
|
||||
: undefined;
|
||||
const thinking =
|
||||
typeof opts.thinking === "string" && opts.thinking.trim()
|
||||
? opts.thinking.trim()
|
||||
: undefined;
|
||||
const timeoutSeconds = opts.timeoutSeconds
|
||||
? Number.parseInt(String(opts.timeoutSeconds), 10)
|
||||
: undefined;
|
||||
patch.payload = {
|
||||
kind: "agentTurn",
|
||||
message: String(opts.message),
|
||||
model,
|
||||
thinking,
|
||||
timeoutSeconds:
|
||||
timeoutSeconds && Number.isFinite(timeoutSeconds)
|
||||
? timeoutSeconds
|
||||
: undefined,
|
||||
deliver: Boolean(opts.deliver),
|
||||
channel:
|
||||
typeof opts.channel === "string" ? opts.channel : undefined,
|
||||
to: typeof opts.to === "string" ? opts.to : undefined,
|
||||
bestEffortDeliver: Boolean(opts.bestEffortDeliver),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof opts.postPrefix === "string") {
|
||||
patch.isolation = {
|
||||
postToMainPrefix: opts.postPrefix.trim()
|
||||
? opts.postPrefix
|
||||
: "Cron",
|
||||
};
|
||||
}
|
||||
|
||||
const res = await callGatewayFromCli("cron.update", opts, {
|
||||
id,
|
||||
patch,
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
await warnIfCronSchedulerDisabled(opts);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
110
src/cli/cron-cli/register.cron-simple.ts
Normal file
110
src/cli/cron-cli/register.cron-simple.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { Command } from "commander";
|
||||
import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
import { warnIfCronSchedulerDisabled } from "./shared.js";
|
||||
|
||||
export function registerCronSimpleCommands(cron: Command) {
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("rm")
|
||||
.alias("remove")
|
||||
.alias("delete")
|
||||
.description("Remove a cron job")
|
||||
.argument("<id>", "Job id")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (id, opts) => {
|
||||
try {
|
||||
const res = await callGatewayFromCli("cron.remove", opts, { id });
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("enable")
|
||||
.description("Enable a cron job")
|
||||
.argument("<id>", "Job id")
|
||||
.action(async (id, opts) => {
|
||||
try {
|
||||
const res = await callGatewayFromCli("cron.update", opts, {
|
||||
id,
|
||||
patch: { enabled: true },
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
await warnIfCronSchedulerDisabled(opts);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("disable")
|
||||
.description("Disable a cron job")
|
||||
.argument("<id>", "Job id")
|
||||
.action(async (id, opts) => {
|
||||
try {
|
||||
const res = await callGatewayFromCli("cron.update", opts, {
|
||||
id,
|
||||
patch: { enabled: false },
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
await warnIfCronSchedulerDisabled(opts);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("runs")
|
||||
.description("Show cron run history (JSONL-backed)")
|
||||
.requiredOption("--id <id>", "Job id")
|
||||
.option("--limit <n>", "Max entries (default 50)", "50")
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const limitRaw = Number.parseInt(String(opts.limit ?? "50"), 10);
|
||||
const limit =
|
||||
Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 50;
|
||||
const id = String(opts.id);
|
||||
const res = await callGatewayFromCli("cron.runs", opts, {
|
||||
id,
|
||||
limit,
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("run")
|
||||
.description("Run a cron job now (debug)")
|
||||
.argument("<id>", "Job id")
|
||||
.option("--force", "Run even if not due", false)
|
||||
.action(async (id, opts) => {
|
||||
try {
|
||||
const res = await callGatewayFromCli("cron.run", opts, {
|
||||
id,
|
||||
mode: opts.force ? "force" : "due",
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
33
src/cli/cron-cli/register.ts
Normal file
33
src/cli/cron-cli/register.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Command } from "commander";
|
||||
import { formatDocsLink } from "../../terminal/links.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import {
|
||||
registerCronAddCommand,
|
||||
registerCronListCommand,
|
||||
registerCronStatusCommand,
|
||||
} from "./register.cron-add.js";
|
||||
import { registerCronEditCommand } from "./register.cron-edit.js";
|
||||
import { registerCronSimpleCommands } from "./register.cron-simple.js";
|
||||
import { registerWakeCommand } from "./register.wake.js";
|
||||
|
||||
export function registerCronCli(program: Command) {
|
||||
registerWakeCommand(program);
|
||||
|
||||
const cron = program
|
||||
.command("cron")
|
||||
.description("Manage cron jobs (via Gateway)")
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.muted("Docs:")} ${formatDocsLink(
|
||||
"/cron-jobs",
|
||||
"docs.clawd.bot/cron-jobs",
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
registerCronStatusCommand(cron);
|
||||
registerCronListCommand(cron);
|
||||
registerCronAddCommand(cron);
|
||||
registerCronSimpleCommands(cron);
|
||||
registerCronEditCommand(cron);
|
||||
}
|
||||
36
src/cli/cron-cli/register.wake.ts
Normal file
36
src/cli/cron-cli/register.wake.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Command } from "commander";
|
||||
import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { GatewayRpcOpts } from "../gateway-rpc.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
|
||||
export function registerWakeCommand(program: Command) {
|
||||
addGatewayClientOptions(
|
||||
program
|
||||
.command("wake")
|
||||
.description(
|
||||
"Enqueue a system event and optionally trigger an immediate heartbeat",
|
||||
)
|
||||
.requiredOption("--text <text>", "System event text")
|
||||
.option(
|
||||
"--mode <mode>",
|
||||
"Wake mode (now|next-heartbeat)",
|
||||
"next-heartbeat",
|
||||
)
|
||||
.option("--json", "Output JSON", false),
|
||||
).action(async (opts: GatewayRpcOpts & { text?: string; mode?: string }) => {
|
||||
try {
|
||||
const result = await callGatewayFromCli(
|
||||
"wake",
|
||||
opts,
|
||||
{ mode: opts.mode, text: opts.text },
|
||||
{ expectFinal: false },
|
||||
);
|
||||
if (opts.json) defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
else defaultRuntime.log("ok");
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
200
src/cli/cron-cli/shared.ts
Normal file
200
src/cli/cron-cli/shared.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { CHANNEL_IDS } from "../../channels/registry.js";
|
||||
import { parseAbsoluteTimeMs } from "../../cron/parse.js";
|
||||
import type { CronJob, CronSchedule } from "../../cron/types.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||
import type { GatewayRpcOpts } from "../gateway-rpc.js";
|
||||
import { callGatewayFromCli } from "../gateway-rpc.js";
|
||||
|
||||
export const CRON_CHANNEL_OPTIONS = ["last", ...CHANNEL_IDS].join("|");
|
||||
|
||||
export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) {
|
||||
try {
|
||||
const res = (await callGatewayFromCli("cron.status", opts, {})) as {
|
||||
enabled?: boolean;
|
||||
storePath?: string;
|
||||
};
|
||||
if (res?.enabled === true) return;
|
||||
const store = typeof res?.storePath === "string" ? res.storePath : "";
|
||||
defaultRuntime.error(
|
||||
[
|
||||
"warning: cron scheduler is disabled in the Gateway; jobs are saved but will not run automatically.",
|
||||
"Re-enable with `cron.enabled: true` (or remove `cron.enabled: false`) and restart the Gateway.",
|
||||
store ? `store: ${store}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
);
|
||||
} catch {
|
||||
// Ignore status failures (older gateway, offline, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDurationMs(input: string): number | null {
|
||||
const raw = input.trim();
|
||||
if (!raw) return null;
|
||||
const match = raw.match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/i);
|
||||
if (!match) return null;
|
||||
const n = Number.parseFloat(match[1] ?? "");
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
const unit = (match[2] ?? "").toLowerCase();
|
||||
const factor =
|
||||
unit === "ms"
|
||||
? 1
|
||||
: unit === "s"
|
||||
? 1000
|
||||
: unit === "m"
|
||||
? 60_000
|
||||
: unit === "h"
|
||||
? 3_600_000
|
||||
: 86_400_000;
|
||||
return Math.floor(n * factor);
|
||||
}
|
||||
|
||||
export function parseAtMs(input: string): number | null {
|
||||
const raw = input.trim();
|
||||
if (!raw) return null;
|
||||
const absolute = parseAbsoluteTimeMs(raw);
|
||||
if (absolute) return absolute;
|
||||
const dur = parseDurationMs(raw);
|
||||
if (dur) return Date.now() + dur;
|
||||
return null;
|
||||
}
|
||||
|
||||
const CRON_ID_PAD = 36;
|
||||
const CRON_NAME_PAD = 24;
|
||||
const CRON_SCHEDULE_PAD = 32;
|
||||
const CRON_NEXT_PAD = 10;
|
||||
const CRON_LAST_PAD = 10;
|
||||
const CRON_STATUS_PAD = 9;
|
||||
const CRON_TARGET_PAD = 9;
|
||||
const CRON_AGENT_PAD = 10;
|
||||
|
||||
const pad = (value: string, width: number) => value.padEnd(width);
|
||||
|
||||
const truncate = (value: string, width: number) => {
|
||||
if (value.length <= width) return value;
|
||||
if (width <= 3) return value.slice(0, width);
|
||||
return `${value.slice(0, width - 3)}...`;
|
||||
};
|
||||
|
||||
const formatIsoMinute = (ms: number) => {
|
||||
const d = new Date(ms);
|
||||
if (Number.isNaN(d.getTime())) return "-";
|
||||
const iso = d.toISOString();
|
||||
return `${iso.slice(0, 10)} ${iso.slice(11, 16)}Z`;
|
||||
};
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
if (ms < 60_000) return `${Math.max(1, Math.round(ms / 1000))}s`;
|
||||
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
|
||||
if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
|
||||
return `${Math.round(ms / 86_400_000)}d`;
|
||||
};
|
||||
|
||||
const formatSpan = (ms: number) => {
|
||||
if (ms < 60_000) return "<1m";
|
||||
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
|
||||
if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
|
||||
return `${Math.round(ms / 86_400_000)}d`;
|
||||
};
|
||||
|
||||
const formatRelative = (ms: number | null | undefined, nowMs: number) => {
|
||||
if (!ms) return "-";
|
||||
const delta = ms - nowMs;
|
||||
const label = formatSpan(Math.abs(delta));
|
||||
return delta >= 0 ? `in ${label}` : `${label} ago`;
|
||||
};
|
||||
|
||||
const formatSchedule = (schedule: CronSchedule) => {
|
||||
if (schedule.kind === "at") return `at ${formatIsoMinute(schedule.atMs)}`;
|
||||
if (schedule.kind === "every")
|
||||
return `every ${formatDuration(schedule.everyMs)}`;
|
||||
return schedule.tz
|
||||
? `cron ${schedule.expr} @ ${schedule.tz}`
|
||||
: `cron ${schedule.expr}`;
|
||||
};
|
||||
|
||||
const formatStatus = (job: CronJob) => {
|
||||
if (!job.enabled) return "disabled";
|
||||
if (job.state.runningAtMs) return "running";
|
||||
return job.state.lastStatus ?? "idle";
|
||||
};
|
||||
|
||||
export function printCronList(jobs: CronJob[], runtime = defaultRuntime) {
|
||||
if (jobs.length === 0) {
|
||||
runtime.log("No cron jobs.");
|
||||
return;
|
||||
}
|
||||
|
||||
const rich = isRich();
|
||||
const header = [
|
||||
pad("ID", CRON_ID_PAD),
|
||||
pad("Name", CRON_NAME_PAD),
|
||||
pad("Schedule", CRON_SCHEDULE_PAD),
|
||||
pad("Next", CRON_NEXT_PAD),
|
||||
pad("Last", CRON_LAST_PAD),
|
||||
pad("Status", CRON_STATUS_PAD),
|
||||
pad("Target", CRON_TARGET_PAD),
|
||||
pad("Agent", CRON_AGENT_PAD),
|
||||
].join(" ");
|
||||
|
||||
runtime.log(rich ? theme.heading(header) : header);
|
||||
const now = Date.now();
|
||||
|
||||
for (const job of jobs) {
|
||||
const idLabel = pad(job.id, CRON_ID_PAD);
|
||||
const nameLabel = pad(truncate(job.name, CRON_NAME_PAD), CRON_NAME_PAD);
|
||||
const scheduleLabel = pad(
|
||||
truncate(formatSchedule(job.schedule), CRON_SCHEDULE_PAD),
|
||||
CRON_SCHEDULE_PAD,
|
||||
);
|
||||
const nextLabel = pad(
|
||||
job.enabled ? formatRelative(job.state.nextRunAtMs, now) : "-",
|
||||
CRON_NEXT_PAD,
|
||||
);
|
||||
const lastLabel = pad(
|
||||
formatRelative(job.state.lastRunAtMs, now),
|
||||
CRON_LAST_PAD,
|
||||
);
|
||||
const statusRaw = formatStatus(job);
|
||||
const statusLabel = pad(statusRaw, CRON_STATUS_PAD);
|
||||
const targetLabel = pad(job.sessionTarget, CRON_TARGET_PAD);
|
||||
const agentLabel = pad(
|
||||
truncate(job.agentId ?? "default", CRON_AGENT_PAD),
|
||||
CRON_AGENT_PAD,
|
||||
);
|
||||
|
||||
const coloredStatus = (() => {
|
||||
if (statusRaw === "ok") return colorize(rich, theme.success, statusLabel);
|
||||
if (statusRaw === "error")
|
||||
return colorize(rich, theme.error, statusLabel);
|
||||
if (statusRaw === "running")
|
||||
return colorize(rich, theme.warn, statusLabel);
|
||||
if (statusRaw === "skipped")
|
||||
return colorize(rich, theme.muted, statusLabel);
|
||||
return colorize(rich, theme.muted, statusLabel);
|
||||
})();
|
||||
|
||||
const coloredTarget =
|
||||
job.sessionTarget === "isolated"
|
||||
? colorize(rich, theme.accentBright, targetLabel)
|
||||
: colorize(rich, theme.accent, targetLabel);
|
||||
const coloredAgent = job.agentId
|
||||
? colorize(rich, theme.info, agentLabel)
|
||||
: colorize(rich, theme.muted, agentLabel);
|
||||
|
||||
const line = [
|
||||
colorize(rich, theme.accent, idLabel),
|
||||
colorize(rich, theme.info, nameLabel),
|
||||
colorize(rich, theme.info, scheduleLabel),
|
||||
colorize(rich, theme.muted, nextLabel),
|
||||
colorize(rich, theme.muted, lastLabel),
|
||||
coloredStatus,
|
||||
coloredTarget,
|
||||
coloredAgent,
|
||||
].join(" ");
|
||||
|
||||
runtime.log(line.trimEnd());
|
||||
}
|
||||
}
|
||||
@@ -55,12 +55,13 @@ vi.mock("../daemon/service.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../daemon/legacy.js", () => ({
|
||||
findLegacyGatewayServices: () => [],
|
||||
findLegacyGatewayServices: async () => [],
|
||||
}));
|
||||
|
||||
vi.mock("../daemon/inspect.js", () => ({
|
||||
findExtraGatewayServices: (env: unknown, opts?: unknown) =>
|
||||
findExtraGatewayServices(env, opts),
|
||||
renderGatewayServiceCleanupHints: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("../infra/ports.js", () => ({
|
||||
@@ -76,6 +77,11 @@ vi.mock("./deps.js", () => ({
|
||||
createDefaultDeps: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("./progress.js", () => ({
|
||||
withProgress: async (_opts: unknown, fn: () => Promise<unknown>) =>
|
||||
await fn(),
|
||||
}));
|
||||
|
||||
describe("daemon-cli coverage", () => {
|
||||
const originalEnv = {
|
||||
CLAWDBOT_STATE_DIR: process.env.CLAWDBOT_STATE_DIR,
|
||||
@@ -128,7 +134,7 @@ describe("daemon-cli coverage", () => {
|
||||
);
|
||||
expect(findExtraGatewayServices).toHaveBeenCalled();
|
||||
expect(inspectPortUsage).toHaveBeenCalled();
|
||||
});
|
||||
}, 20_000);
|
||||
|
||||
it("derives probe URL from service args + env (json)", async () => {
|
||||
runtimeLogs.length = 0;
|
||||
@@ -162,7 +168,8 @@ describe("daemon-cli coverage", () => {
|
||||
);
|
||||
expect(inspectPortUsage).toHaveBeenCalledWith(19001);
|
||||
|
||||
const parsed = JSON.parse(runtimeLogs[0] ?? "{}") as {
|
||||
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
|
||||
const parsed = JSON.parse(jsonLine ?? "{}") as {
|
||||
gateway?: { port?: number; portSource?: string; probeUrl?: string };
|
||||
config?: { mismatch?: boolean };
|
||||
rpc?: { url?: string; ok?: boolean };
|
||||
@@ -173,7 +180,7 @@ describe("daemon-cli coverage", () => {
|
||||
expect(parsed.config?.mismatch).toBe(true);
|
||||
expect(parsed.rpc?.url).toBe("ws://127.0.0.1:19001");
|
||||
expect(parsed.rpc?.ok).toBe(true);
|
||||
});
|
||||
}, 20_000);
|
||||
|
||||
it("passes deep scan flag for daemon status", async () => {
|
||||
findExtraGatewayServices.mockClear();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
112
src/cli/daemon-cli/install.ts
Normal file
112
src/cli/daemon-cli/install.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||
isGatewayDaemonRuntime,
|
||||
} from "../../commands/daemon-runtime.js";
|
||||
import { loadConfig, resolveGatewayPort } from "../../config/config.js";
|
||||
import { resolveIsNixMode } from "../../config/paths.js";
|
||||
import { resolveGatewayLaunchAgentLabel } from "../../daemon/constants.js";
|
||||
import { resolveGatewayProgramArguments } from "../../daemon/program-args.js";
|
||||
import {
|
||||
renderSystemNodeWarning,
|
||||
resolvePreferredNodePath,
|
||||
resolveSystemNodeInfo,
|
||||
} from "../../daemon/runtime-paths.js";
|
||||
import { resolveGatewayService } from "../../daemon/service.js";
|
||||
import { buildServiceEnvironment } from "../../daemon/service-env.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { parsePort } from "./shared.js";
|
||||
import type { DaemonInstallOptions } from "./types.js";
|
||||
|
||||
export async function runDaemonInstall(opts: DaemonInstallOptions) {
|
||||
if (resolveIsNixMode(process.env)) {
|
||||
defaultRuntime.error("Nix mode detected; daemon install is disabled.");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
const portOverride = parsePort(opts.port);
|
||||
if (opts.port !== undefined && portOverride === null) {
|
||||
defaultRuntime.error("Invalid port");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const port = portOverride ?? resolveGatewayPort(cfg);
|
||||
if (!Number.isFinite(port) || port <= 0) {
|
||||
defaultRuntime.error("Invalid port");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const runtimeRaw = opts.runtime
|
||||
? String(opts.runtime)
|
||||
: DEFAULT_GATEWAY_DAEMON_RUNTIME;
|
||||
if (!isGatewayDaemonRuntime(runtimeRaw)) {
|
||||
defaultRuntime.error('Invalid --runtime (use "node" or "bun")');
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const service = resolveGatewayService();
|
||||
const profile = process.env.CLAWDBOT_PROFILE;
|
||||
let loaded = false;
|
||||
try {
|
||||
loaded = await service.isLoaded({ env: process.env, profile });
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (loaded) {
|
||||
if (!opts.force) {
|
||||
defaultRuntime.log(`Gateway service already ${service.loadedText}.`);
|
||||
defaultRuntime.log("Reinstall with: clawdbot daemon install --force");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const devMode =
|
||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
||||
process.argv[1]?.endsWith(".ts");
|
||||
const nodePath = await resolvePreferredNodePath({
|
||||
env: process.env,
|
||||
runtime: runtimeRaw,
|
||||
});
|
||||
const { programArguments, workingDirectory } =
|
||||
await resolveGatewayProgramArguments({
|
||||
port,
|
||||
dev: devMode,
|
||||
runtime: runtimeRaw,
|
||||
nodePath,
|
||||
});
|
||||
if (runtimeRaw === "node") {
|
||||
const systemNode = await resolveSystemNodeInfo({ env: process.env });
|
||||
const warning = renderSystemNodeWarning(systemNode, programArguments[0]);
|
||||
if (warning) defaultRuntime.log(warning);
|
||||
}
|
||||
const environment = buildServiceEnvironment({
|
||||
env: process.env,
|
||||
port,
|
||||
token:
|
||||
opts.token ||
|
||||
cfg.gateway?.auth?.token ||
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
launchdLabel:
|
||||
process.platform === "darwin"
|
||||
? resolveGatewayLaunchAgentLabel(profile)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
await service.install({
|
||||
env: process.env,
|
||||
stdout: process.stdout,
|
||||
programArguments,
|
||||
workingDirectory,
|
||||
environment,
|
||||
});
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`Gateway install failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}
|
||||
113
src/cli/daemon-cli/lifecycle.ts
Normal file
113
src/cli/daemon-cli/lifecycle.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { resolveIsNixMode } from "../../config/paths.js";
|
||||
import { resolveGatewayService } from "../../daemon/service.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { renderGatewayServiceStartHints } from "./shared.js";
|
||||
|
||||
export async function runDaemonUninstall() {
|
||||
if (resolveIsNixMode(process.env)) {
|
||||
defaultRuntime.error("Nix mode detected; daemon uninstall is disabled.");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const service = resolveGatewayService();
|
||||
try {
|
||||
await service.uninstall({ env: process.env, stdout: process.stdout });
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`Gateway uninstall failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDaemonStart() {
|
||||
const service = resolveGatewayService();
|
||||
const profile = process.env.CLAWDBOT_PROFILE;
|
||||
let loaded = false;
|
||||
try {
|
||||
loaded = await service.isLoaded({ env: process.env, profile });
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (!loaded) {
|
||||
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
|
||||
for (const hint of renderGatewayServiceStartHints()) {
|
||||
defaultRuntime.log(`Start with: ${hint}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await service.restart({
|
||||
env: process.env,
|
||||
profile,
|
||||
stdout: process.stdout,
|
||||
});
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`Gateway start failed: ${String(err)}`);
|
||||
for (const hint of renderGatewayServiceStartHints()) {
|
||||
defaultRuntime.error(`Start with: ${hint}`);
|
||||
}
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDaemonStop() {
|
||||
const service = resolveGatewayService();
|
||||
const profile = process.env.CLAWDBOT_PROFILE;
|
||||
let loaded = false;
|
||||
try {
|
||||
loaded = await service.isLoaded({ env: process.env, profile });
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (!loaded) {
|
||||
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await service.stop({ env: process.env, profile, stdout: process.stdout });
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`Gateway stop failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the gateway daemon service.
|
||||
* @returns `true` if restart succeeded, `false` if the service was not loaded.
|
||||
* Throws/exits on check or restart failures.
|
||||
*/
|
||||
export async function runDaemonRestart(): Promise<boolean> {
|
||||
const service = resolveGatewayService();
|
||||
const profile = process.env.CLAWDBOT_PROFILE;
|
||||
let loaded = false;
|
||||
try {
|
||||
loaded = await service.isLoaded({ env: process.env, profile });
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
return false;
|
||||
}
|
||||
if (!loaded) {
|
||||
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
|
||||
for (const hint of renderGatewayServiceStartHints()) {
|
||||
defaultRuntime.log(`Start with: ${hint}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await service.restart({
|
||||
env: process.env,
|
||||
profile,
|
||||
stdout: process.stdout,
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`Gateway restart failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
42
src/cli/daemon-cli/probe.ts
Normal file
42
src/cli/daemon-cli/probe.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
import {
|
||||
GATEWAY_CLIENT_MODES,
|
||||
GATEWAY_CLIENT_NAMES,
|
||||
} from "../../utils/message-channel.js";
|
||||
import { withProgress } from "../progress.js";
|
||||
|
||||
export async function probeGatewayStatus(opts: {
|
||||
url: string;
|
||||
token?: string;
|
||||
password?: string;
|
||||
timeoutMs: number;
|
||||
json?: boolean;
|
||||
configPath?: string;
|
||||
}) {
|
||||
try {
|
||||
await withProgress(
|
||||
{
|
||||
label: "Checking gateway status...",
|
||||
indeterminate: true,
|
||||
enabled: opts.json !== true,
|
||||
},
|
||||
async () =>
|
||||
await callGateway({
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
password: opts.password,
|
||||
method: "status",
|
||||
timeoutMs: opts.timeoutMs,
|
||||
clientName: GATEWAY_CLIENT_NAMES.CLI,
|
||||
mode: GATEWAY_CLIENT_MODES.CLI,
|
||||
...(opts.configPath ? { configPath: opts.configPath } : {}),
|
||||
}),
|
||||
);
|
||||
return { ok: true } as const;
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
90
src/cli/daemon-cli/register.ts
Normal file
90
src/cli/daemon-cli/register.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { Command } from "commander";
|
||||
import { formatDocsLink } from "../../terminal/links.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import { createDefaultDeps } from "../deps.js";
|
||||
import {
|
||||
runDaemonInstall,
|
||||
runDaemonRestart,
|
||||
runDaemonStart,
|
||||
runDaemonStatus,
|
||||
runDaemonStop,
|
||||
runDaemonUninstall,
|
||||
} from "./runners.js";
|
||||
|
||||
export function registerDaemonCli(program: Command) {
|
||||
const daemon = program
|
||||
.command("daemon")
|
||||
.description("Manage the Gateway daemon service (launchd/systemd/schtasks)")
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.muted("Docs:")} ${formatDocsLink(
|
||||
"/gateway",
|
||||
"docs.clawd.bot/gateway",
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
daemon
|
||||
.command("status")
|
||||
.description("Show daemon install status + probe the Gateway")
|
||||
.option(
|
||||
"--url <url>",
|
||||
"Gateway WebSocket URL (defaults to config/remote/local)",
|
||||
)
|
||||
.option("--token <token>", "Gateway token (if required)")
|
||||
.option("--password <password>", "Gateway password (password auth)")
|
||||
.option("--timeout <ms>", "Timeout in ms", "10000")
|
||||
.option("--no-probe", "Skip RPC probe")
|
||||
.option("--deep", "Scan system-level services", false)
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runDaemonStatus({
|
||||
rpc: opts,
|
||||
probe: Boolean(opts.probe),
|
||||
deep: Boolean(opts.deep),
|
||||
json: Boolean(opts.json),
|
||||
});
|
||||
});
|
||||
|
||||
daemon
|
||||
.command("install")
|
||||
.description("Install the Gateway service (launchd/systemd/schtasks)")
|
||||
.option("--port <port>", "Gateway port")
|
||||
.option("--runtime <runtime>", "Daemon runtime (node|bun). Default: node")
|
||||
.option("--token <token>", "Gateway token (token auth)")
|
||||
.option("--force", "Reinstall/overwrite if already installed", false)
|
||||
.action(async (opts) => {
|
||||
await runDaemonInstall(opts);
|
||||
});
|
||||
|
||||
daemon
|
||||
.command("uninstall")
|
||||
.description("Uninstall the Gateway service (launchd/systemd/schtasks)")
|
||||
.action(async () => {
|
||||
await runDaemonUninstall();
|
||||
});
|
||||
|
||||
daemon
|
||||
.command("start")
|
||||
.description("Start the Gateway service (launchd/systemd/schtasks)")
|
||||
.action(async () => {
|
||||
await runDaemonStart();
|
||||
});
|
||||
|
||||
daemon
|
||||
.command("stop")
|
||||
.description("Stop the Gateway service (launchd/systemd/schtasks)")
|
||||
.action(async () => {
|
||||
await runDaemonStop();
|
||||
});
|
||||
|
||||
daemon
|
||||
.command("restart")
|
||||
.description("Restart the Gateway service (launchd/systemd/schtasks)")
|
||||
.action(async () => {
|
||||
await runDaemonRestart();
|
||||
});
|
||||
|
||||
// Build default deps (parity with other commands).
|
||||
void createDefaultDeps();
|
||||
}
|
||||
8
src/cli/daemon-cli/runners.ts
Normal file
8
src/cli/daemon-cli/runners.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { runDaemonInstall } from "./install.js";
|
||||
export {
|
||||
runDaemonRestart,
|
||||
runDaemonStart,
|
||||
runDaemonStop,
|
||||
runDaemonUninstall,
|
||||
} from "./lifecycle.js";
|
||||
export { runDaemonStatus } from "./status.js";
|
||||
176
src/cli/daemon-cli/shared.ts
Normal file
176
src/cli/daemon-cli/shared.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
resolveGatewayLaunchAgentLabel,
|
||||
resolveGatewaySystemdServiceName,
|
||||
resolveGatewayWindowsTaskName,
|
||||
} from "../../daemon/constants.js";
|
||||
import { resolveGatewayLogPaths } from "../../daemon/launchd.js";
|
||||
import { getResolvedLoggerSettings } from "../../logging.js";
|
||||
|
||||
export function parsePort(raw: unknown): number | null {
|
||||
if (raw === undefined || raw === null) return null;
|
||||
const value =
|
||||
typeof raw === "string"
|
||||
? raw
|
||||
: typeof raw === "number" || typeof raw === "bigint"
|
||||
? raw.toString()
|
||||
: null;
|
||||
if (value === null) return null;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parsePortFromArgs(
|
||||
programArguments: string[] | undefined,
|
||||
): number | null {
|
||||
if (!programArguments?.length) return null;
|
||||
for (let i = 0; i < programArguments.length; i += 1) {
|
||||
const arg = programArguments[i];
|
||||
if (arg === "--port") {
|
||||
const next = programArguments[i + 1];
|
||||
const parsed = parsePort(next);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
if (arg?.startsWith("--port=")) {
|
||||
const parsed = parsePort(arg.split("=", 2)[1]);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function pickProbeHostForBind(
|
||||
bindMode: string,
|
||||
tailnetIPv4: string | undefined,
|
||||
customBindHost?: string,
|
||||
) {
|
||||
if (bindMode === "custom" && customBindHost?.trim()) {
|
||||
return customBindHost.trim();
|
||||
}
|
||||
if (bindMode === "auto") return tailnetIPv4 ?? "127.0.0.1";
|
||||
return "127.0.0.1";
|
||||
}
|
||||
|
||||
export function safeDaemonEnv(
|
||||
env: Record<string, string> | undefined,
|
||||
): string[] {
|
||||
if (!env) return [];
|
||||
const allow = [
|
||||
"CLAWDBOT_PROFILE",
|
||||
"CLAWDBOT_STATE_DIR",
|
||||
"CLAWDBOT_CONFIG_PATH",
|
||||
"CLAWDBOT_GATEWAY_PORT",
|
||||
"CLAWDBOT_NIX_MODE",
|
||||
];
|
||||
const lines: string[] = [];
|
||||
for (const key of allow) {
|
||||
const value = env[key];
|
||||
if (!value?.trim()) continue;
|
||||
lines.push(`${key}=${value.trim()}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function normalizeListenerAddress(raw: string): string {
|
||||
let value = raw.trim();
|
||||
if (!value) return value;
|
||||
value = value.replace(/^TCP\s+/i, "");
|
||||
value = value.replace(/\s+\(LISTEN\)\s*$/i, "");
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function formatRuntimeStatus(
|
||||
runtime:
|
||||
| {
|
||||
status?: string;
|
||||
state?: string;
|
||||
subState?: string;
|
||||
pid?: number;
|
||||
lastExitStatus?: number;
|
||||
lastExitReason?: string;
|
||||
lastRunResult?: string;
|
||||
lastRunTime?: string;
|
||||
detail?: string;
|
||||
}
|
||||
| undefined,
|
||||
) {
|
||||
if (!runtime) return null;
|
||||
const status = runtime.status ?? "unknown";
|
||||
const details: string[] = [];
|
||||
if (runtime.pid) details.push(`pid ${runtime.pid}`);
|
||||
if (runtime.state && runtime.state.toLowerCase() !== status) {
|
||||
details.push(`state ${runtime.state}`);
|
||||
}
|
||||
if (runtime.subState) details.push(`sub ${runtime.subState}`);
|
||||
if (runtime.lastExitStatus !== undefined) {
|
||||
details.push(`last exit ${runtime.lastExitStatus}`);
|
||||
}
|
||||
if (runtime.lastExitReason) details.push(`reason ${runtime.lastExitReason}`);
|
||||
if (runtime.lastRunResult) details.push(`last run ${runtime.lastRunResult}`);
|
||||
if (runtime.lastRunTime) details.push(`last run time ${runtime.lastRunTime}`);
|
||||
if (runtime.detail) details.push(runtime.detail);
|
||||
return details.length > 0 ? `${status} (${details.join(", ")})` : status;
|
||||
}
|
||||
|
||||
export function renderRuntimeHints(
|
||||
runtime: { missingUnit?: boolean; status?: string } | undefined,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string[] {
|
||||
if (!runtime) return [];
|
||||
const hints: string[] = [];
|
||||
const fileLog = (() => {
|
||||
try {
|
||||
return getResolvedLoggerSettings().file;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (runtime.missingUnit) {
|
||||
hints.push("Service not installed. Run: clawdbot daemon install");
|
||||
if (fileLog) hints.push(`File logs: ${fileLog}`);
|
||||
return hints;
|
||||
}
|
||||
if (runtime.status === "stopped") {
|
||||
if (fileLog) hints.push(`File logs: ${fileLog}`);
|
||||
if (process.platform === "darwin") {
|
||||
const logs = resolveGatewayLogPaths(env);
|
||||
hints.push(`Launchd stdout (if installed): ${logs.stdoutPath}`);
|
||||
hints.push(`Launchd stderr (if installed): ${logs.stderrPath}`);
|
||||
} else if (process.platform === "linux") {
|
||||
const unit = resolveGatewaySystemdServiceName(env.CLAWDBOT_PROFILE);
|
||||
hints.push(
|
||||
`Logs: journalctl --user -u ${unit}.service -n 200 --no-pager`,
|
||||
);
|
||||
} else if (process.platform === "win32") {
|
||||
const task = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE);
|
||||
hints.push(`Logs: schtasks /Query /TN "${task}" /V /FO LIST`);
|
||||
}
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
|
||||
export function renderGatewayServiceStartHints(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string[] {
|
||||
const base = ["clawdbot daemon install", "clawdbot gateway"];
|
||||
const profile = env.CLAWDBOT_PROFILE;
|
||||
switch (process.platform) {
|
||||
case "darwin": {
|
||||
const label = resolveGatewayLaunchAgentLabel(profile);
|
||||
return [
|
||||
...base,
|
||||
`launchctl bootstrap gui/$UID ~/Library/LaunchAgents/${label}.plist`,
|
||||
];
|
||||
}
|
||||
case "linux": {
|
||||
const unit = resolveGatewaySystemdServiceName(profile);
|
||||
return [...base, `systemctl --user start ${unit}.service`];
|
||||
}
|
||||
case "win32": {
|
||||
const task = resolveGatewayWindowsTaskName(profile);
|
||||
return [...base, `schtasks /Run /TN "${task}"`];
|
||||
}
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
}
|
||||
332
src/cli/daemon-cli/status.gather.ts
Normal file
332
src/cli/daemon-cli/status.gather.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import {
|
||||
createConfigIO,
|
||||
resolveConfigPath,
|
||||
resolveGatewayPort,
|
||||
resolveStateDir,
|
||||
} from "../../config/config.js";
|
||||
import type {
|
||||
BridgeBindMode,
|
||||
GatewayControlUiConfig,
|
||||
} from "../../config/types.js";
|
||||
import { readLastGatewayErrorLine } from "../../daemon/diagnostics.js";
|
||||
import type { FindExtraGatewayServicesOptions } from "../../daemon/inspect.js";
|
||||
import { findExtraGatewayServices } from "../../daemon/inspect.js";
|
||||
import { findLegacyGatewayServices } from "../../daemon/legacy.js";
|
||||
import { resolveGatewayService } from "../../daemon/service.js";
|
||||
import type { ServiceConfigAudit } from "../../daemon/service-audit.js";
|
||||
import { auditGatewayServiceConfig } from "../../daemon/service-audit.js";
|
||||
import { resolveGatewayBindHost } from "../../gateway/net.js";
|
||||
import {
|
||||
formatPortDiagnostics,
|
||||
inspectPortUsage,
|
||||
type PortListener,
|
||||
type PortUsageStatus,
|
||||
} from "../../infra/ports.js";
|
||||
import { pickPrimaryTailnetIPv4 } from "../../infra/tailnet.js";
|
||||
import { probeGatewayStatus } from "./probe.js";
|
||||
import {
|
||||
normalizeListenerAddress,
|
||||
parsePortFromArgs,
|
||||
pickProbeHostForBind,
|
||||
} from "./shared.js";
|
||||
import type { GatewayRpcOpts } from "./types.js";
|
||||
|
||||
type ConfigSummary = {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
valid: boolean;
|
||||
issues?: Array<{ path: string; message: string }>;
|
||||
controlUi?: GatewayControlUiConfig;
|
||||
};
|
||||
|
||||
type GatewayStatusSummary = {
|
||||
bindMode: BridgeBindMode;
|
||||
bindHost: string;
|
||||
customBindHost?: string;
|
||||
port: number;
|
||||
portSource: "service args" | "env/config";
|
||||
probeUrl: string;
|
||||
probeNote?: string;
|
||||
};
|
||||
|
||||
export type DaemonStatus = {
|
||||
service: {
|
||||
label: string;
|
||||
loaded: boolean;
|
||||
loadedText: string;
|
||||
notLoadedText: string;
|
||||
command?: {
|
||||
programArguments: string[];
|
||||
workingDirectory?: string;
|
||||
environment?: Record<string, string>;
|
||||
sourcePath?: string;
|
||||
} | null;
|
||||
runtime?: {
|
||||
status?: string;
|
||||
state?: string;
|
||||
subState?: string;
|
||||
pid?: number;
|
||||
lastExitStatus?: number;
|
||||
lastExitReason?: string;
|
||||
lastRunResult?: string;
|
||||
lastRunTime?: string;
|
||||
detail?: string;
|
||||
cachedLabel?: boolean;
|
||||
missingUnit?: boolean;
|
||||
};
|
||||
configAudit?: ServiceConfigAudit;
|
||||
};
|
||||
config?: {
|
||||
cli: ConfigSummary;
|
||||
daemon?: ConfigSummary;
|
||||
mismatch?: boolean;
|
||||
};
|
||||
gateway?: GatewayStatusSummary;
|
||||
port?: {
|
||||
port: number;
|
||||
status: PortUsageStatus;
|
||||
listeners: PortListener[];
|
||||
hints: string[];
|
||||
};
|
||||
portCli?: {
|
||||
port: number;
|
||||
status: PortUsageStatus;
|
||||
listeners: PortListener[];
|
||||
hints: string[];
|
||||
};
|
||||
lastError?: string;
|
||||
rpc?: {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
url?: string;
|
||||
};
|
||||
legacyServices: Array<{ label: string; detail: string }>;
|
||||
extraServices: Array<{ label: string; detail: string; scope: string }>;
|
||||
};
|
||||
|
||||
function shouldReportPortUsage(
|
||||
status: PortUsageStatus | undefined,
|
||||
rpcOk?: boolean,
|
||||
) {
|
||||
if (status !== "busy") return false;
|
||||
if (rpcOk === true) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function gatherDaemonStatus(
|
||||
opts: {
|
||||
rpc: GatewayRpcOpts;
|
||||
probe: boolean;
|
||||
deep?: boolean;
|
||||
} & FindExtraGatewayServicesOptions,
|
||||
): Promise<DaemonStatus> {
|
||||
const service = resolveGatewayService();
|
||||
const [loaded, command, runtime] = await Promise.all([
|
||||
service
|
||||
.isLoaded({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
})
|
||||
.catch(() => false),
|
||||
service.readCommand(process.env).catch(() => null),
|
||||
service.readRuntime(process.env).catch(() => undefined),
|
||||
]);
|
||||
const configAudit = await auditGatewayServiceConfig({
|
||||
env: process.env,
|
||||
command,
|
||||
});
|
||||
|
||||
const serviceEnv = command?.environment ?? undefined;
|
||||
const mergedDaemonEnv = {
|
||||
...(process.env as Record<string, string | undefined>),
|
||||
...(serviceEnv ?? undefined),
|
||||
} satisfies Record<string, string | undefined>;
|
||||
|
||||
const cliConfigPath = resolveConfigPath(
|
||||
process.env,
|
||||
resolveStateDir(process.env),
|
||||
);
|
||||
const daemonConfigPath = resolveConfigPath(
|
||||
mergedDaemonEnv as NodeJS.ProcessEnv,
|
||||
resolveStateDir(mergedDaemonEnv as NodeJS.ProcessEnv),
|
||||
);
|
||||
|
||||
const cliIO = createConfigIO({ env: process.env, configPath: cliConfigPath });
|
||||
const daemonIO = createConfigIO({
|
||||
env: mergedDaemonEnv,
|
||||
configPath: daemonConfigPath,
|
||||
});
|
||||
|
||||
const [cliSnapshot, daemonSnapshot] = await Promise.all([
|
||||
cliIO.readConfigFileSnapshot().catch(() => null),
|
||||
daemonIO.readConfigFileSnapshot().catch(() => null),
|
||||
]);
|
||||
const cliCfg = cliIO.loadConfig();
|
||||
const daemonCfg = daemonIO.loadConfig();
|
||||
|
||||
const cliConfigSummary: ConfigSummary = {
|
||||
path: cliSnapshot?.path ?? cliConfigPath,
|
||||
exists: cliSnapshot?.exists ?? false,
|
||||
valid: cliSnapshot?.valid ?? true,
|
||||
...(cliSnapshot?.issues?.length ? { issues: cliSnapshot.issues } : {}),
|
||||
controlUi: cliCfg.gateway?.controlUi,
|
||||
};
|
||||
const daemonConfigSummary: ConfigSummary = {
|
||||
path: daemonSnapshot?.path ?? daemonConfigPath,
|
||||
exists: daemonSnapshot?.exists ?? false,
|
||||
valid: daemonSnapshot?.valid ?? true,
|
||||
...(daemonSnapshot?.issues?.length
|
||||
? { issues: daemonSnapshot.issues }
|
||||
: {}),
|
||||
controlUi: daemonCfg.gateway?.controlUi,
|
||||
};
|
||||
const configMismatch = cliConfigSummary.path !== daemonConfigSummary.path;
|
||||
|
||||
const portFromArgs = parsePortFromArgs(command?.programArguments);
|
||||
const daemonPort =
|
||||
portFromArgs ?? resolveGatewayPort(daemonCfg, mergedDaemonEnv);
|
||||
const portSource: GatewayStatusSummary["portSource"] = portFromArgs
|
||||
? "service args"
|
||||
: "env/config";
|
||||
|
||||
const bindMode = (daemonCfg.gateway?.bind ?? "loopback") as
|
||||
| "auto"
|
||||
| "lan"
|
||||
| "loopback"
|
||||
| "custom";
|
||||
const customBindHost = daemonCfg.gateway?.customBindHost;
|
||||
const bindHost = await resolveGatewayBindHost(bindMode, customBindHost);
|
||||
const tailnetIPv4 = pickPrimaryTailnetIPv4();
|
||||
const probeHost = pickProbeHostForBind(bindMode, tailnetIPv4, customBindHost);
|
||||
const probeUrlOverride =
|
||||
typeof opts.rpc.url === "string" && opts.rpc.url.trim().length > 0
|
||||
? opts.rpc.url.trim()
|
||||
: null;
|
||||
const probeUrl = probeUrlOverride ?? `ws://${probeHost}:${daemonPort}`;
|
||||
const probeNote =
|
||||
!probeUrlOverride && bindMode === "lan"
|
||||
? "Local probe uses loopback (127.0.0.1). bind=lan listens on 0.0.0.0 (all interfaces); use a LAN IP for remote clients."
|
||||
: !probeUrlOverride && bindMode === "loopback"
|
||||
? "Loopback-only gateway; only local clients can connect."
|
||||
: undefined;
|
||||
|
||||
const cliPort = resolveGatewayPort(cliCfg, process.env);
|
||||
const [portDiagnostics, portCliDiagnostics] = await Promise.all([
|
||||
inspectPortUsage(daemonPort).catch(() => null),
|
||||
cliPort !== daemonPort ? inspectPortUsage(cliPort).catch(() => null) : null,
|
||||
]);
|
||||
const portStatus: DaemonStatus["port"] | undefined = portDiagnostics
|
||||
? {
|
||||
port: portDiagnostics.port,
|
||||
status: portDiagnostics.status,
|
||||
listeners: portDiagnostics.listeners,
|
||||
hints: portDiagnostics.hints,
|
||||
}
|
||||
: undefined;
|
||||
const portCliStatus: DaemonStatus["portCli"] | undefined = portCliDiagnostics
|
||||
? {
|
||||
port: portCliDiagnostics.port,
|
||||
status: portCliDiagnostics.status,
|
||||
listeners: portCliDiagnostics.listeners,
|
||||
hints: portCliDiagnostics.hints,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const legacyServices = await findLegacyGatewayServices(
|
||||
process.env as Record<string, string | undefined>,
|
||||
).catch(() => []);
|
||||
const extraServices = await findExtraGatewayServices(
|
||||
process.env as Record<string, string | undefined>,
|
||||
{ deep: Boolean(opts.deep) },
|
||||
).catch(() => []);
|
||||
|
||||
const timeoutMsRaw = Number.parseInt(String(opts.rpc.timeout ?? "10000"), 10);
|
||||
const timeoutMs =
|
||||
Number.isFinite(timeoutMsRaw) && timeoutMsRaw > 0 ? timeoutMsRaw : 10_000;
|
||||
|
||||
const rpc = opts.probe
|
||||
? await probeGatewayStatus({
|
||||
url: probeUrl,
|
||||
token:
|
||||
opts.rpc.token ||
|
||||
mergedDaemonEnv.CLAWDBOT_GATEWAY_TOKEN ||
|
||||
daemonCfg.gateway?.auth?.token,
|
||||
password:
|
||||
opts.rpc.password ||
|
||||
mergedDaemonEnv.CLAWDBOT_GATEWAY_PASSWORD ||
|
||||
daemonCfg.gateway?.auth?.password,
|
||||
timeoutMs,
|
||||
json: opts.rpc.json,
|
||||
configPath: daemonConfigSummary.path,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
let lastError: string | undefined;
|
||||
if (
|
||||
loaded &&
|
||||
runtime?.status === "running" &&
|
||||
portStatus &&
|
||||
portStatus.status !== "busy"
|
||||
) {
|
||||
lastError =
|
||||
(await readLastGatewayErrorLine(mergedDaemonEnv as NodeJS.ProcessEnv)) ??
|
||||
undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
service: {
|
||||
label: service.label,
|
||||
loaded,
|
||||
loadedText: service.loadedText,
|
||||
notLoadedText: service.notLoadedText,
|
||||
command,
|
||||
runtime,
|
||||
configAudit,
|
||||
},
|
||||
config: {
|
||||
cli: cliConfigSummary,
|
||||
daemon: daemonConfigSummary,
|
||||
...(configMismatch ? { mismatch: true } : {}),
|
||||
},
|
||||
gateway: {
|
||||
bindMode,
|
||||
bindHost,
|
||||
customBindHost,
|
||||
port: daemonPort,
|
||||
portSource,
|
||||
probeUrl,
|
||||
...(probeNote ? { probeNote } : {}),
|
||||
},
|
||||
port: portStatus,
|
||||
...(portCliStatus ? { portCli: portCliStatus } : {}),
|
||||
lastError,
|
||||
...(rpc ? { rpc: { ...rpc, url: probeUrl } } : {}),
|
||||
legacyServices,
|
||||
extraServices,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderPortDiagnosticsForCli(
|
||||
status: DaemonStatus,
|
||||
rpcOk?: boolean,
|
||||
): string[] {
|
||||
if (!status.port || !shouldReportPortUsage(status.port.status, rpcOk))
|
||||
return [];
|
||||
return formatPortDiagnostics({
|
||||
port: status.port.port,
|
||||
status: status.port.status,
|
||||
listeners: status.port.listeners,
|
||||
hints: status.port.hints,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolvePortListeningAddresses(status: DaemonStatus): string[] {
|
||||
const addrs = Array.from(
|
||||
new Set(
|
||||
status.port?.listeners
|
||||
?.map((l) => (l.address ? normalizeListenerAddress(l.address) : ""))
|
||||
.filter((v): v is string => Boolean(v)) ?? [],
|
||||
),
|
||||
);
|
||||
return addrs;
|
||||
}
|
||||
328
src/cli/daemon-cli/status.print.ts
Normal file
328
src/cli/daemon-cli/status.print.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import { resolveControlUiLinks } from "../../commands/onboard-helpers.js";
|
||||
import {
|
||||
resolveGatewayLaunchAgentLabel,
|
||||
resolveGatewaySystemdServiceName,
|
||||
} from "../../daemon/constants.js";
|
||||
import { renderGatewayServiceCleanupHints } from "../../daemon/inspect.js";
|
||||
import { resolveGatewayLogPaths } from "../../daemon/launchd.js";
|
||||
import { getResolvedLoggerSettings } from "../../logging.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||
import {
|
||||
formatRuntimeStatus,
|
||||
renderRuntimeHints,
|
||||
safeDaemonEnv,
|
||||
} from "./shared.js";
|
||||
import {
|
||||
type DaemonStatus,
|
||||
renderPortDiagnosticsForCli,
|
||||
resolvePortListeningAddresses,
|
||||
} from "./status.gather.js";
|
||||
|
||||
export function printDaemonStatus(
|
||||
status: DaemonStatus,
|
||||
opts: { json: boolean },
|
||||
) {
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(status, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const rich = isRich();
|
||||
const label = (value: string) => colorize(rich, theme.muted, value);
|
||||
const accent = (value: string) => colorize(rich, theme.accent, value);
|
||||
const infoText = (value: string) => colorize(rich, theme.info, value);
|
||||
const okText = (value: string) => colorize(rich, theme.success, value);
|
||||
const warnText = (value: string) => colorize(rich, theme.warn, value);
|
||||
const errorText = (value: string) => colorize(rich, theme.error, value);
|
||||
const spacer = () => defaultRuntime.log("");
|
||||
|
||||
const { service, rpc, legacyServices, extraServices } = status;
|
||||
const serviceStatus = service.loaded
|
||||
? okText(service.loadedText)
|
||||
: warnText(service.notLoadedText);
|
||||
defaultRuntime.log(
|
||||
`${label("Service:")} ${accent(service.label)} (${serviceStatus})`,
|
||||
);
|
||||
try {
|
||||
const logFile = getResolvedLoggerSettings().file;
|
||||
defaultRuntime.log(`${label("File logs:")} ${infoText(logFile)}`);
|
||||
} catch {
|
||||
// ignore missing config/log resolution
|
||||
}
|
||||
if (service.command?.programArguments?.length) {
|
||||
defaultRuntime.log(
|
||||
`${label("Command:")} ${infoText(service.command.programArguments.join(" "))}`,
|
||||
);
|
||||
}
|
||||
if (service.command?.sourcePath) {
|
||||
defaultRuntime.log(
|
||||
`${label("Service file:")} ${infoText(service.command.sourcePath)}`,
|
||||
);
|
||||
}
|
||||
if (service.command?.workingDirectory) {
|
||||
defaultRuntime.log(
|
||||
`${label("Working dir:")} ${infoText(service.command.workingDirectory)}`,
|
||||
);
|
||||
}
|
||||
const daemonEnvLines = safeDaemonEnv(service.command?.environment);
|
||||
if (daemonEnvLines.length > 0) {
|
||||
defaultRuntime.log(`${label("Daemon env:")} ${daemonEnvLines.join(" ")}`);
|
||||
}
|
||||
spacer();
|
||||
|
||||
if (service.configAudit?.issues.length) {
|
||||
defaultRuntime.error(
|
||||
warnText("Service config looks out of date or non-standard."),
|
||||
);
|
||||
for (const issue of service.configAudit.issues) {
|
||||
const detail = issue.detail ? ` (${issue.detail})` : "";
|
||||
defaultRuntime.error(
|
||||
`${warnText("Service config issue:")} ${issue.message}${detail}`,
|
||||
);
|
||||
}
|
||||
defaultRuntime.error(
|
||||
warnText(
|
||||
'Recommendation: run "clawdbot doctor" (or "clawdbot doctor --repair").',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (status.config) {
|
||||
const cliCfg = `${status.config.cli.path}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`;
|
||||
defaultRuntime.log(`${label("Config (cli):")} ${infoText(cliCfg)}`);
|
||||
if (!status.config.cli.valid && status.config.cli.issues?.length) {
|
||||
for (const issue of status.config.cli.issues.slice(0, 5)) {
|
||||
defaultRuntime.error(
|
||||
`${errorText("Config issue:")} ${issue.path || "<root>"}: ${issue.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (status.config.daemon) {
|
||||
const daemonCfg = `${status.config.daemon.path}${status.config.daemon.exists ? "" : " (missing)"}${status.config.daemon.valid ? "" : " (invalid)"}`;
|
||||
defaultRuntime.log(`${label("Config (daemon):")} ${infoText(daemonCfg)}`);
|
||||
if (!status.config.daemon.valid && status.config.daemon.issues?.length) {
|
||||
for (const issue of status.config.daemon.issues.slice(0, 5)) {
|
||||
defaultRuntime.error(
|
||||
`${errorText("Daemon config issue:")} ${issue.path || "<root>"}: ${issue.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (status.config.mismatch) {
|
||||
defaultRuntime.error(
|
||||
errorText(
|
||||
"Root cause: CLI and daemon are using different config paths (likely a profile/state-dir mismatch).",
|
||||
),
|
||||
);
|
||||
defaultRuntime.error(
|
||||
errorText(
|
||||
"Fix: rerun `clawdbot daemon install --force` from the same --profile / CLAWDBOT_STATE_DIR you expect.",
|
||||
),
|
||||
);
|
||||
}
|
||||
spacer();
|
||||
}
|
||||
|
||||
if (status.gateway) {
|
||||
const bindHost = status.gateway.bindHost ?? "n/a";
|
||||
defaultRuntime.log(
|
||||
`${label("Gateway:")} bind=${infoText(status.gateway.bindMode)} (${infoText(bindHost)}), port=${infoText(String(status.gateway.port))} (${infoText(status.gateway.portSource)})`,
|
||||
);
|
||||
defaultRuntime.log(
|
||||
`${label("Probe target:")} ${infoText(status.gateway.probeUrl)}`,
|
||||
);
|
||||
const controlUiEnabled = status.config?.daemon?.controlUi?.enabled ?? true;
|
||||
if (!controlUiEnabled) {
|
||||
defaultRuntime.log(`${label("Dashboard:")} ${warnText("disabled")}`);
|
||||
} else {
|
||||
const links = resolveControlUiLinks({
|
||||
port: status.gateway.port,
|
||||
bind: status.gateway.bindMode,
|
||||
customBindHost: status.gateway.customBindHost,
|
||||
basePath: status.config?.daemon?.controlUi?.basePath,
|
||||
});
|
||||
defaultRuntime.log(`${label("Dashboard:")} ${infoText(links.httpUrl)}`);
|
||||
}
|
||||
if (status.gateway.probeNote) {
|
||||
defaultRuntime.log(
|
||||
`${label("Probe note:")} ${infoText(status.gateway.probeNote)}`,
|
||||
);
|
||||
}
|
||||
spacer();
|
||||
}
|
||||
|
||||
const runtimeLine = formatRuntimeStatus(service.runtime);
|
||||
if (runtimeLine) {
|
||||
const runtimeStatus = service.runtime?.status ?? "unknown";
|
||||
const runtimeColor =
|
||||
runtimeStatus === "running"
|
||||
? theme.success
|
||||
: runtimeStatus === "stopped"
|
||||
? theme.error
|
||||
: runtimeStatus === "unknown"
|
||||
? theme.muted
|
||||
: theme.warn;
|
||||
defaultRuntime.log(
|
||||
`${label("Runtime:")} ${colorize(rich, runtimeColor, runtimeLine)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
rpc &&
|
||||
!rpc.ok &&
|
||||
service.loaded &&
|
||||
service.runtime?.status === "running"
|
||||
) {
|
||||
defaultRuntime.log(
|
||||
warnText(
|
||||
"Warm-up: launch agents can take a few seconds. Try again shortly.",
|
||||
),
|
||||
);
|
||||
}
|
||||
if (rpc) {
|
||||
if (rpc.ok) {
|
||||
defaultRuntime.log(`${label("RPC probe:")} ${okText("ok")}`);
|
||||
} else {
|
||||
defaultRuntime.error(`${label("RPC probe:")} ${errorText("failed")}`);
|
||||
if (rpc.url) defaultRuntime.error(`${label("RPC target:")} ${rpc.url}`);
|
||||
const lines = String(rpc.error ?? "unknown")
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean);
|
||||
for (const line of lines.slice(0, 12)) {
|
||||
defaultRuntime.error(` ${errorText(line)}`);
|
||||
}
|
||||
}
|
||||
spacer();
|
||||
}
|
||||
|
||||
if (service.runtime?.missingUnit) {
|
||||
defaultRuntime.error(errorText("Service unit not found."));
|
||||
for (const hint of renderRuntimeHints(service.runtime)) {
|
||||
defaultRuntime.error(errorText(hint));
|
||||
}
|
||||
} else if (service.loaded && service.runtime?.status === "stopped") {
|
||||
defaultRuntime.error(
|
||||
errorText(
|
||||
"Service is loaded but not running (likely exited immediately).",
|
||||
),
|
||||
);
|
||||
for (const hint of renderRuntimeHints(
|
||||
service.runtime,
|
||||
(service.command?.environment ?? process.env) as NodeJS.ProcessEnv,
|
||||
)) {
|
||||
defaultRuntime.error(errorText(hint));
|
||||
}
|
||||
spacer();
|
||||
}
|
||||
|
||||
if (service.runtime?.cachedLabel) {
|
||||
const env = (service.command?.environment ??
|
||||
process.env) as NodeJS.ProcessEnv;
|
||||
const labelValue = resolveGatewayLaunchAgentLabel(env.CLAWDBOT_PROFILE);
|
||||
defaultRuntime.error(
|
||||
errorText(
|
||||
`LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${labelValue}`,
|
||||
),
|
||||
);
|
||||
defaultRuntime.error(errorText("Then reinstall: clawdbot daemon install"));
|
||||
spacer();
|
||||
}
|
||||
|
||||
for (const line of renderPortDiagnosticsForCli(status, rpc?.ok)) {
|
||||
defaultRuntime.error(errorText(line));
|
||||
}
|
||||
|
||||
if (status.port) {
|
||||
const addrs = resolvePortListeningAddresses(status);
|
||||
if (addrs.length > 0) {
|
||||
defaultRuntime.log(
|
||||
`${label("Listening:")} ${infoText(addrs.join(", "))}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (status.portCli && status.portCli.port !== status.port?.port) {
|
||||
defaultRuntime.log(
|
||||
`${label("Note:")} CLI config resolves gateway port=${status.portCli.port} (${status.portCli.status}).`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
service.loaded &&
|
||||
service.runtime?.status === "running" &&
|
||||
status.port &&
|
||||
status.port.status !== "busy"
|
||||
) {
|
||||
defaultRuntime.error(
|
||||
errorText(
|
||||
`Gateway port ${status.port.port} is not listening (service appears running).`,
|
||||
),
|
||||
);
|
||||
if (status.lastError) {
|
||||
defaultRuntime.error(
|
||||
`${errorText("Last gateway error:")} ${status.lastError}`,
|
||||
);
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
const env = (service.command?.environment ??
|
||||
process.env) as NodeJS.ProcessEnv;
|
||||
const unit = resolveGatewaySystemdServiceName(env.CLAWDBOT_PROFILE);
|
||||
defaultRuntime.error(
|
||||
errorText(
|
||||
`Logs: journalctl --user -u ${unit}.service -n 200 --no-pager`,
|
||||
),
|
||||
);
|
||||
} else if (process.platform === "darwin") {
|
||||
const logs = resolveGatewayLogPaths(
|
||||
(service.command?.environment ?? process.env) as NodeJS.ProcessEnv,
|
||||
);
|
||||
defaultRuntime.error(`${errorText("Logs:")} ${logs.stdoutPath}`);
|
||||
defaultRuntime.error(`${errorText("Errors:")} ${logs.stderrPath}`);
|
||||
}
|
||||
spacer();
|
||||
}
|
||||
|
||||
if (legacyServices.length > 0) {
|
||||
defaultRuntime.error(errorText("Legacy Clawdis services detected:"));
|
||||
for (const svc of legacyServices) {
|
||||
defaultRuntime.error(`- ${errorText(svc.label)} (${svc.detail})`);
|
||||
}
|
||||
defaultRuntime.error(errorText("Cleanup: clawdbot doctor"));
|
||||
spacer();
|
||||
}
|
||||
|
||||
if (extraServices.length > 0) {
|
||||
defaultRuntime.error(
|
||||
errorText("Other gateway-like services detected (best effort):"),
|
||||
);
|
||||
for (const svc of extraServices) {
|
||||
defaultRuntime.error(
|
||||
`- ${errorText(svc.label)} (${svc.scope}, ${svc.detail})`,
|
||||
);
|
||||
}
|
||||
for (const hint of renderGatewayServiceCleanupHints()) {
|
||||
defaultRuntime.error(`${errorText("Cleanup hint:")} ${hint}`);
|
||||
}
|
||||
spacer();
|
||||
}
|
||||
|
||||
if (legacyServices.length > 0 || extraServices.length > 0) {
|
||||
defaultRuntime.error(
|
||||
errorText(
|
||||
"Recommendation: run a single gateway per machine. One gateway supports multiple agents.",
|
||||
),
|
||||
);
|
||||
defaultRuntime.error(
|
||||
errorText(
|
||||
"If you need multiple gateways, isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).",
|
||||
),
|
||||
);
|
||||
spacer();
|
||||
}
|
||||
|
||||
defaultRuntime.log(`${label("Troubles:")} run clawdbot status`);
|
||||
defaultRuntime.log(
|
||||
`${label("Troubleshooting:")} https://docs.clawd.bot/troubleshooting`,
|
||||
);
|
||||
}
|
||||
22
src/cli/daemon-cli/status.ts
Normal file
22
src/cli/daemon-cli/status.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||
import { gatherDaemonStatus } from "./status.gather.js";
|
||||
import { printDaemonStatus } from "./status.print.js";
|
||||
import type { DaemonStatusOptions } from "./types.js";
|
||||
|
||||
export async function runDaemonStatus(opts: DaemonStatusOptions) {
|
||||
try {
|
||||
const status = await gatherDaemonStatus({
|
||||
rpc: opts.rpc,
|
||||
probe: Boolean(opts.probe),
|
||||
deep: Boolean(opts.deep),
|
||||
});
|
||||
printDaemonStatus(status, { json: Boolean(opts.json) });
|
||||
} catch (err) {
|
||||
const rich = isRich();
|
||||
defaultRuntime.error(
|
||||
colorize(rich, theme.error, `Daemon status failed: ${String(err)}`),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}
|
||||
22
src/cli/daemon-cli/types.ts
Normal file
22
src/cli/daemon-cli/types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { FindExtraGatewayServicesOptions } from "../../daemon/inspect.js";
|
||||
|
||||
export type GatewayRpcOpts = {
|
||||
url?: string;
|
||||
token?: string;
|
||||
password?: string;
|
||||
timeout?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
export type DaemonStatusOptions = {
|
||||
rpc: GatewayRpcOpts;
|
||||
probe: boolean;
|
||||
json: boolean;
|
||||
} & FindExtraGatewayServicesOptions;
|
||||
|
||||
export type DaemonInstallOptions = {
|
||||
port?: string | number;
|
||||
runtime?: string;
|
||||
token?: string;
|
||||
force?: boolean;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
53
src/cli/gateway-cli/call.ts
Normal file
53
src/cli/gateway-cli/call.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { Command } from "commander";
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
import {
|
||||
GATEWAY_CLIENT_MODES,
|
||||
GATEWAY_CLIENT_NAMES,
|
||||
} from "../../utils/message-channel.js";
|
||||
import { withProgress } from "../progress.js";
|
||||
|
||||
export type GatewayRpcOpts = {
|
||||
url?: string;
|
||||
token?: string;
|
||||
password?: string;
|
||||
timeout?: string;
|
||||
expectFinal?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
export const gatewayCallOpts = (cmd: Command) =>
|
||||
cmd
|
||||
.option(
|
||||
"--url <url>",
|
||||
"Gateway WebSocket URL (defaults to gateway.remote.url when configured)",
|
||||
)
|
||||
.option("--token <token>", "Gateway token (if required)")
|
||||
.option("--password <password>", "Gateway password (password auth)")
|
||||
.option("--timeout <ms>", "Timeout in ms", "10000")
|
||||
.option("--expect-final", "Wait for final response (agent)", false)
|
||||
.option("--json", "Output JSON", false);
|
||||
|
||||
export const callGatewayCli = async (
|
||||
method: string,
|
||||
opts: GatewayRpcOpts,
|
||||
params?: unknown,
|
||||
) =>
|
||||
withProgress(
|
||||
{
|
||||
label: `Gateway ${method}`,
|
||||
indeterminate: true,
|
||||
enabled: opts.json !== true,
|
||||
},
|
||||
async () =>
|
||||
await callGateway({
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
password: opts.password,
|
||||
method,
|
||||
params,
|
||||
expectFinal: Boolean(opts.expectFinal),
|
||||
timeoutMs: Number(opts.timeout ?? 10_000),
|
||||
clientName: GATEWAY_CLIENT_NAMES.CLI,
|
||||
mode: GATEWAY_CLIENT_MODES.CLI,
|
||||
}),
|
||||
);
|
||||
130
src/cli/gateway-cli/dev.ts
Normal file
130
src/cli/gateway-cli/dev.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
|
||||
import { handleReset } from "../../commands/onboard-helpers.js";
|
||||
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../../config/config.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
|
||||
const DEV_IDENTITY_NAME = "C3-PO";
|
||||
const DEV_IDENTITY_THEME = "protocol droid";
|
||||
const DEV_IDENTITY_EMOJI = "🤖";
|
||||
const DEV_AGENT_WORKSPACE_SUFFIX = "dev";
|
||||
|
||||
const DEV_TEMPLATE_DIR = path.resolve(
|
||||
path.dirname(new URL(import.meta.url).pathname),
|
||||
"../../../docs/reference/templates",
|
||||
);
|
||||
|
||||
async function loadDevTemplate(
|
||||
name: string,
|
||||
fallback: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const raw = await fs.promises.readFile(
|
||||
path.join(DEV_TEMPLATE_DIR, name),
|
||||
"utf-8",
|
||||
);
|
||||
if (!raw.startsWith("---")) return raw;
|
||||
const endIndex = raw.indexOf("\n---", 3);
|
||||
if (endIndex === -1) return raw;
|
||||
return raw.slice(endIndex + "\n---".length).replace(/^\s+/, "");
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
const resolveDevWorkspaceDir = (
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string => {
|
||||
const baseDir = resolveDefaultAgentWorkspaceDir(env, os.homedir);
|
||||
const profile = env.CLAWDBOT_PROFILE?.trim().toLowerCase();
|
||||
if (profile === "dev") return baseDir;
|
||||
return `${baseDir}-${DEV_AGENT_WORKSPACE_SUFFIX}`;
|
||||
};
|
||||
|
||||
async function writeFileIfMissing(filePath: string, content: string) {
|
||||
try {
|
||||
await fs.promises.writeFile(filePath, content, {
|
||||
encoding: "utf-8",
|
||||
flag: "wx",
|
||||
});
|
||||
} catch (err) {
|
||||
const anyErr = err as { code?: string };
|
||||
if (anyErr.code !== "EEXIST") throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDevWorkspace(dir: string) {
|
||||
const resolvedDir = resolveUserPath(dir);
|
||||
await fs.promises.mkdir(resolvedDir, { recursive: true });
|
||||
|
||||
const [agents, soul, tools, identity, user] = await Promise.all([
|
||||
loadDevTemplate(
|
||||
"AGENTS.dev.md",
|
||||
`# AGENTS.md - Clawdbot Dev Workspace\n\nDefault dev workspace for clawdbot gateway --dev.\n`,
|
||||
),
|
||||
loadDevTemplate(
|
||||
"SOUL.dev.md",
|
||||
`# SOUL.md - Dev Persona\n\nProtocol droid for debugging and operations.\n`,
|
||||
),
|
||||
loadDevTemplate(
|
||||
"TOOLS.dev.md",
|
||||
`# TOOLS.md - User Tool Notes (editable)\n\nAdd your local tool notes here.\n`,
|
||||
),
|
||||
loadDevTemplate(
|
||||
"IDENTITY.dev.md",
|
||||
`# IDENTITY.md - Agent Identity\n\n- Name: ${DEV_IDENTITY_NAME}\n- Creature: protocol droid\n- Vibe: ${DEV_IDENTITY_THEME}\n- Emoji: ${DEV_IDENTITY_EMOJI}\n`,
|
||||
),
|
||||
loadDevTemplate(
|
||||
"USER.dev.md",
|
||||
`# USER.md - User Profile\n\n- Name:\n- Preferred address:\n- Notes:\n`,
|
||||
),
|
||||
]);
|
||||
|
||||
await writeFileIfMissing(path.join(resolvedDir, "AGENTS.md"), agents);
|
||||
await writeFileIfMissing(path.join(resolvedDir, "SOUL.md"), soul);
|
||||
await writeFileIfMissing(path.join(resolvedDir, "TOOLS.md"), tools);
|
||||
await writeFileIfMissing(path.join(resolvedDir, "IDENTITY.md"), identity);
|
||||
await writeFileIfMissing(path.join(resolvedDir, "USER.md"), user);
|
||||
}
|
||||
|
||||
export async function ensureDevGatewayConfig(opts: { reset?: boolean }) {
|
||||
const workspace = resolveDevWorkspaceDir();
|
||||
if (opts.reset) {
|
||||
await handleReset("full", workspace, defaultRuntime);
|
||||
}
|
||||
|
||||
const configExists = fs.existsSync(CONFIG_PATH_CLAWDBOT);
|
||||
if (!opts.reset && configExists) return;
|
||||
|
||||
await writeConfigFile({
|
||||
gateway: {
|
||||
mode: "local",
|
||||
bind: "loopback",
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace,
|
||||
skipBootstrap: true,
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "dev",
|
||||
default: true,
|
||||
workspace,
|
||||
identity: {
|
||||
name: DEV_IDENTITY_NAME,
|
||||
theme: DEV_IDENTITY_THEME,
|
||||
emoji: DEV_IDENTITY_EMOJI,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await ensureDevWorkspace(workspace);
|
||||
defaultRuntime.log(`Dev config ready: ${CONFIG_PATH_CLAWDBOT}`);
|
||||
defaultRuntime.log(`Dev workspace ready: ${resolveUserPath(workspace)}`);
|
||||
}
|
||||
108
src/cli/gateway-cli/discover.ts
Normal file
108
src/cli/gateway-cli/discover.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { GatewayBonjourBeacon } from "../../infra/bonjour-discovery.js";
|
||||
import { colorize, theme } from "../../terminal/theme.js";
|
||||
|
||||
export type GatewayDiscoverOpts = {
|
||||
timeout?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
export function parseDiscoverTimeoutMs(
|
||||
raw: unknown,
|
||||
fallbackMs: number,
|
||||
): number {
|
||||
if (raw === undefined || raw === null) return fallbackMs;
|
||||
const value =
|
||||
typeof raw === "string"
|
||||
? raw.trim()
|
||||
: typeof raw === "number" || typeof raw === "bigint"
|
||||
? String(raw)
|
||||
: null;
|
||||
if (value === null) {
|
||||
throw new Error("invalid --timeout");
|
||||
}
|
||||
if (!value) return fallbackMs;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error(`invalid --timeout: ${value}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function pickBeaconHost(beacon: GatewayBonjourBeacon): string | null {
|
||||
const host = beacon.tailnetDns || beacon.lanHost || beacon.host;
|
||||
return host?.trim() ? host.trim() : null;
|
||||
}
|
||||
|
||||
export function pickGatewayPort(beacon: GatewayBonjourBeacon): number {
|
||||
const port = beacon.gatewayPort ?? 18789;
|
||||
return port > 0 ? port : 18789;
|
||||
}
|
||||
|
||||
export function dedupeBeacons(
|
||||
beacons: GatewayBonjourBeacon[],
|
||||
): GatewayBonjourBeacon[] {
|
||||
const out: GatewayBonjourBeacon[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const b of beacons) {
|
||||
const host = pickBeaconHost(b) ?? "";
|
||||
const key = [
|
||||
b.domain ?? "",
|
||||
b.instanceName ?? "",
|
||||
b.displayName ?? "",
|
||||
host,
|
||||
String(b.port ?? ""),
|
||||
String(b.bridgePort ?? ""),
|
||||
String(b.gatewayPort ?? ""),
|
||||
].join("|");
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(b);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function renderBeaconLines(
|
||||
beacon: GatewayBonjourBeacon,
|
||||
rich: boolean,
|
||||
): string[] {
|
||||
const nameRaw = (
|
||||
beacon.displayName ||
|
||||
beacon.instanceName ||
|
||||
"Gateway"
|
||||
).trim();
|
||||
const domainRaw = (beacon.domain || "local.").trim();
|
||||
|
||||
const title = colorize(rich, theme.accentBright, nameRaw);
|
||||
const domain = colorize(rich, theme.muted, domainRaw);
|
||||
|
||||
const host = pickBeaconHost(beacon);
|
||||
const gatewayPort = pickGatewayPort(beacon);
|
||||
const wsUrl = host ? `ws://${host}:${gatewayPort}` : null;
|
||||
|
||||
const lines = [`- ${title} ${domain}`];
|
||||
|
||||
if (beacon.tailnetDns) {
|
||||
lines.push(
|
||||
` ${colorize(rich, theme.info, "tailnet")}: ${beacon.tailnetDns}`,
|
||||
);
|
||||
}
|
||||
if (beacon.lanHost) {
|
||||
lines.push(` ${colorize(rich, theme.info, "lan")}: ${beacon.lanHost}`);
|
||||
}
|
||||
if (beacon.host) {
|
||||
lines.push(` ${colorize(rich, theme.info, "host")}: ${beacon.host}`);
|
||||
}
|
||||
|
||||
if (wsUrl) {
|
||||
lines.push(
|
||||
` ${colorize(rich, theme.muted, "ws")}: ${colorize(rich, theme.command, wsUrl)}`,
|
||||
);
|
||||
}
|
||||
if (typeof beacon.sshPort === "number" && beacon.sshPort > 0 && host) {
|
||||
const ssh = `ssh -N -L 18789:127.0.0.1:18789 <user>@${host} -p ${beacon.sshPort}`;
|
||||
lines.push(
|
||||
` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, ssh)}`,
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
210
src/cli/gateway-cli/register.ts
Normal file
210
src/cli/gateway-cli/register.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type { Command } from "commander";
|
||||
import { gatewayStatusCommand } from "../../commands/gateway-status.js";
|
||||
import {
|
||||
formatHealthChannelLines,
|
||||
type HealthSummary,
|
||||
} from "../../commands/health.js";
|
||||
import { discoverGatewayBeacons } from "../../infra/bonjour-discovery.js";
|
||||
import { WIDE_AREA_DISCOVERY_DOMAIN } from "../../infra/widearea-dns.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { formatDocsLink } from "../../terminal/links.js";
|
||||
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||
import { withProgress } from "../progress.js";
|
||||
import { callGatewayCli, gatewayCallOpts } from "./call.js";
|
||||
import type { GatewayDiscoverOpts } from "./discover.js";
|
||||
import {
|
||||
dedupeBeacons,
|
||||
parseDiscoverTimeoutMs,
|
||||
pickBeaconHost,
|
||||
pickGatewayPort,
|
||||
renderBeaconLines,
|
||||
} from "./discover.js";
|
||||
import { addGatewayRunCommand } from "./run.js";
|
||||
|
||||
export function registerGatewayCli(program: Command) {
|
||||
const gateway = addGatewayRunCommand(
|
||||
program
|
||||
.command("gateway")
|
||||
.description("Run the WebSocket Gateway")
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.muted("Docs:")} ${formatDocsLink(
|
||||
"/gateway",
|
||||
"docs.clawd.bot/gateway",
|
||||
)}\n`,
|
||||
),
|
||||
);
|
||||
|
||||
// Back-compat: legacy launchd plists used gateway-daemon; keep hidden alias.
|
||||
addGatewayRunCommand(
|
||||
program
|
||||
.command("gateway-daemon", { hidden: true })
|
||||
.description("Run the WebSocket Gateway as a long-lived daemon"),
|
||||
{ legacyTokenEnv: true },
|
||||
);
|
||||
|
||||
gatewayCallOpts(
|
||||
gateway
|
||||
.command("call")
|
||||
.description("Call a Gateway method")
|
||||
.argument(
|
||||
"<method>",
|
||||
"Method name (health/status/system-presence/cron.*)",
|
||||
)
|
||||
.option("--params <json>", "JSON object string for params", "{}")
|
||||
.action(async (method, opts) => {
|
||||
try {
|
||||
const params = JSON.parse(String(opts.params ?? "{}"));
|
||||
const result = await callGatewayCli(method, opts, params);
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
const rich = isRich();
|
||||
defaultRuntime.log(
|
||||
`${colorize(rich, theme.heading, "Gateway call")}: ${colorize(rich, theme.muted, String(method))}`,
|
||||
);
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`Gateway call failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
gatewayCallOpts(
|
||||
gateway
|
||||
.command("health")
|
||||
.description("Fetch Gateway health")
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const result = await callGatewayCli("health", opts);
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
const rich = isRich();
|
||||
const obj =
|
||||
result && typeof result === "object"
|
||||
? (result as Record<string, unknown>)
|
||||
: {};
|
||||
const durationMs =
|
||||
typeof obj.durationMs === "number" ? obj.durationMs : null;
|
||||
defaultRuntime.log(colorize(rich, theme.heading, "Gateway Health"));
|
||||
defaultRuntime.log(
|
||||
`${colorize(rich, theme.success, "OK")}${durationMs != null ? ` (${durationMs}ms)` : ""}`,
|
||||
);
|
||||
if (obj.channels && typeof obj.channels === "object") {
|
||||
for (const line of formatHealthChannelLines(obj as HealthSummary)) {
|
||||
defaultRuntime.log(line);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
gateway
|
||||
.command("status")
|
||||
.description(
|
||||
"Show gateway reachability + discovery + health + status summary (local + remote)",
|
||||
)
|
||||
.option(
|
||||
"--url <url>",
|
||||
"Explicit Gateway WebSocket URL (still probes localhost)",
|
||||
)
|
||||
.option(
|
||||
"--ssh <target>",
|
||||
"SSH target for remote gateway tunnel (user@host or user@host:port)",
|
||||
)
|
||||
.option("--ssh-identity <path>", "SSH identity file path")
|
||||
.option(
|
||||
"--ssh-auto",
|
||||
"Try to derive an SSH target from Bonjour discovery",
|
||||
false,
|
||||
)
|
||||
.option("--token <token>", "Gateway token (applies to all probes)")
|
||||
.option("--password <password>", "Gateway password (applies to all probes)")
|
||||
.option("--timeout <ms>", "Overall probe budget in ms", "3000")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
await gatewayStatusCommand(opts, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
gateway
|
||||
.command("discover")
|
||||
.description(
|
||||
`Discover gateways via Bonjour (multicast local. + unicast ${WIDE_AREA_DISCOVERY_DOMAIN})`,
|
||||
)
|
||||
.option("--timeout <ms>", "Per-command timeout in ms", "2000")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts: GatewayDiscoverOpts) => {
|
||||
try {
|
||||
const timeoutMs = parseDiscoverTimeoutMs(opts.timeout, 2000);
|
||||
const beacons = await withProgress(
|
||||
{
|
||||
label: "Scanning for gateways…",
|
||||
indeterminate: true,
|
||||
enabled: opts.json !== true,
|
||||
delayMs: 0,
|
||||
},
|
||||
async () => await discoverGatewayBeacons({ timeoutMs }),
|
||||
);
|
||||
|
||||
const deduped = dedupeBeacons(beacons).sort((a, b) =>
|
||||
String(a.displayName || a.instanceName).localeCompare(
|
||||
String(b.displayName || b.instanceName),
|
||||
),
|
||||
);
|
||||
|
||||
if (opts.json) {
|
||||
const enriched = deduped.map((b) => {
|
||||
const host = pickBeaconHost(b);
|
||||
const port = pickGatewayPort(b);
|
||||
return { ...b, wsUrl: host ? `ws://${host}:${port}` : null };
|
||||
});
|
||||
defaultRuntime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
timeoutMs,
|
||||
domains: ["local.", WIDE_AREA_DISCOVERY_DOMAIN],
|
||||
count: enriched.length,
|
||||
beacons: enriched,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const rich = isRich();
|
||||
defaultRuntime.log(colorize(rich, theme.heading, "Gateway Discovery"));
|
||||
defaultRuntime.log(
|
||||
colorize(
|
||||
rich,
|
||||
theme.muted,
|
||||
`Found ${deduped.length} gateway(s) · domains: local., ${WIDE_AREA_DISCOVERY_DOMAIN}`,
|
||||
),
|
||||
);
|
||||
if (deduped.length === 0) return;
|
||||
|
||||
for (const beacon of deduped) {
|
||||
for (const line of renderBeaconLines(beacon, rich)) {
|
||||
defaultRuntime.log(line);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`gateway discover failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
92
src/cli/gateway-cli/run-loop.ts
Normal file
92
src/cli/gateway-cli/run-loop.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { startGatewayServer } from "../../gateway/server.js";
|
||||
import { createSubsystemLogger } from "../../logging.js";
|
||||
import type { defaultRuntime } from "../../runtime.js";
|
||||
|
||||
const gatewayLog = createSubsystemLogger("gateway");
|
||||
|
||||
type GatewayRunSignalAction = "stop" | "restart";
|
||||
|
||||
export async function runGatewayLoop(params: {
|
||||
start: () => Promise<Awaited<ReturnType<typeof startGatewayServer>>>;
|
||||
runtime: typeof defaultRuntime;
|
||||
}) {
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>> | null = null;
|
||||
let shuttingDown = false;
|
||||
let restartResolver: (() => void) | null = null;
|
||||
|
||||
const cleanupSignals = () => {
|
||||
process.removeListener("SIGTERM", onSigterm);
|
||||
process.removeListener("SIGINT", onSigint);
|
||||
process.removeListener("SIGUSR1", onSigusr1);
|
||||
};
|
||||
|
||||
const request = (action: GatewayRunSignalAction, signal: string) => {
|
||||
if (shuttingDown) {
|
||||
gatewayLog.info(`received ${signal} during shutdown; ignoring`);
|
||||
return;
|
||||
}
|
||||
shuttingDown = true;
|
||||
const isRestart = action === "restart";
|
||||
gatewayLog.info(
|
||||
`received ${signal}; ${isRestart ? "restarting" : "shutting down"}`,
|
||||
);
|
||||
|
||||
const forceExitTimer = setTimeout(() => {
|
||||
gatewayLog.error("shutdown timed out; exiting without full cleanup");
|
||||
cleanupSignals();
|
||||
params.runtime.exit(0);
|
||||
}, 5000);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await server?.close({
|
||||
reason: isRestart ? "gateway restarting" : "gateway stopping",
|
||||
restartExpectedMs: isRestart ? 1500 : null,
|
||||
});
|
||||
} catch (err) {
|
||||
gatewayLog.error(`shutdown error: ${String(err)}`);
|
||||
} finally {
|
||||
clearTimeout(forceExitTimer);
|
||||
server = null;
|
||||
if (isRestart) {
|
||||
shuttingDown = false;
|
||||
restartResolver?.();
|
||||
} else {
|
||||
cleanupSignals();
|
||||
params.runtime.exit(0);
|
||||
}
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const onSigterm = () => {
|
||||
gatewayLog.info("signal SIGTERM received");
|
||||
request("stop", "SIGTERM");
|
||||
};
|
||||
const onSigint = () => {
|
||||
gatewayLog.info("signal SIGINT received");
|
||||
request("stop", "SIGINT");
|
||||
};
|
||||
const onSigusr1 = () => {
|
||||
gatewayLog.info("signal SIGUSR1 received");
|
||||
request("restart", "SIGUSR1");
|
||||
};
|
||||
|
||||
process.on("SIGTERM", onSigterm);
|
||||
process.on("SIGINT", onSigint);
|
||||
process.on("SIGUSR1", onSigusr1);
|
||||
|
||||
try {
|
||||
// Keep process alive; SIGUSR1 triggers an in-process restart (no supervisor required).
|
||||
// SIGTERM/SIGINT still exit after a graceful shutdown.
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
server = await params.start();
|
||||
await new Promise<void>((resolve) => {
|
||||
restartResolver = resolve;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
cleanupSignals();
|
||||
}
|
||||
}
|
||||
398
src/cli/gateway-cli/run.ts
Normal file
398
src/cli/gateway-cli/run.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
import type { Command } from "commander";
|
||||
import type { GatewayAuthMode } from "../../config/config.js";
|
||||
import {
|
||||
CONFIG_PATH_CLAWDBOT,
|
||||
loadConfig,
|
||||
readConfigFileSnapshot,
|
||||
resolveGatewayPort,
|
||||
} from "../../config/config.js";
|
||||
import { resolveGatewayAuth } from "../../gateway/auth.js";
|
||||
import { startGatewayServer } from "../../gateway/server.js";
|
||||
import type { GatewayWsLogStyle } from "../../gateway/ws-logging.js";
|
||||
import { setGatewayWsLogStyle } from "../../gateway/ws-logging.js";
|
||||
import { setVerbose } from "../../globals.js";
|
||||
import { GatewayLockError } from "../../infra/gateway-lock.js";
|
||||
import { formatPortDiagnostics, inspectPortUsage } from "../../infra/ports.js";
|
||||
import {
|
||||
createSubsystemLogger,
|
||||
setConsoleSubsystemFilter,
|
||||
} from "../../logging.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { forceFreePortAndWait } from "../ports.js";
|
||||
import { ensureDevGatewayConfig } from "./dev.js";
|
||||
import { runGatewayLoop } from "./run-loop.js";
|
||||
import {
|
||||
describeUnknownError,
|
||||
extractGatewayMiskeys,
|
||||
maybeExplainGatewayServiceStop,
|
||||
parsePort,
|
||||
toOptionString,
|
||||
} from "./shared.js";
|
||||
|
||||
type GatewayRunOpts = {
|
||||
port?: unknown;
|
||||
bind?: unknown;
|
||||
token?: unknown;
|
||||
auth?: unknown;
|
||||
password?: unknown;
|
||||
tailscale?: unknown;
|
||||
tailscaleResetOnExit?: boolean;
|
||||
allowUnconfigured?: boolean;
|
||||
force?: boolean;
|
||||
verbose?: boolean;
|
||||
claudeCliLogs?: boolean;
|
||||
wsLog?: unknown;
|
||||
compact?: boolean;
|
||||
rawStream?: boolean;
|
||||
rawStreamPath?: unknown;
|
||||
dev?: boolean;
|
||||
reset?: boolean;
|
||||
};
|
||||
|
||||
type GatewayRunParams = {
|
||||
legacyTokenEnv?: boolean;
|
||||
};
|
||||
|
||||
const gatewayLog = createSubsystemLogger("gateway");
|
||||
|
||||
async function runGatewayCommand(
|
||||
opts: GatewayRunOpts,
|
||||
params: GatewayRunParams = {},
|
||||
) {
|
||||
const isDevProfile =
|
||||
process.env.CLAWDBOT_PROFILE?.trim().toLowerCase() === "dev";
|
||||
const devMode = Boolean(opts.dev) || isDevProfile;
|
||||
if (opts.reset && !devMode) {
|
||||
defaultRuntime.error("Use --reset with --dev.");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (params.legacyTokenEnv) {
|
||||
const legacyToken = process.env.CLAWDIS_GATEWAY_TOKEN;
|
||||
if (legacyToken && !process.env.CLAWDBOT_GATEWAY_TOKEN) {
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = legacyToken;
|
||||
}
|
||||
}
|
||||
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
if (opts.claudeCliLogs) {
|
||||
setConsoleSubsystemFilter(["agent/claude-cli"]);
|
||||
process.env.CLAWDBOT_CLAUDE_CLI_LOG_OUTPUT = "1";
|
||||
}
|
||||
const wsLogRaw = (opts.compact ? "compact" : opts.wsLog) as
|
||||
| string
|
||||
| undefined;
|
||||
const wsLogStyle: GatewayWsLogStyle =
|
||||
wsLogRaw === "compact" ? "compact" : wsLogRaw === "full" ? "full" : "auto";
|
||||
if (
|
||||
wsLogRaw !== undefined &&
|
||||
wsLogRaw !== "auto" &&
|
||||
wsLogRaw !== "compact" &&
|
||||
wsLogRaw !== "full"
|
||||
) {
|
||||
defaultRuntime.error('Invalid --ws-log (use "auto", "full", "compact")');
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
setGatewayWsLogStyle(wsLogStyle);
|
||||
|
||||
if (opts.rawStream) {
|
||||
process.env.CLAWDBOT_RAW_STREAM = "1";
|
||||
}
|
||||
const rawStreamPath = toOptionString(opts.rawStreamPath);
|
||||
if (rawStreamPath) {
|
||||
process.env.CLAWDBOT_RAW_STREAM_PATH = rawStreamPath;
|
||||
}
|
||||
|
||||
if (devMode) {
|
||||
await ensureDevGatewayConfig({ reset: Boolean(opts.reset) });
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
const portOverride = parsePort(opts.port);
|
||||
if (opts.port !== undefined && portOverride === null) {
|
||||
defaultRuntime.error("Invalid port");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
const port = portOverride ?? resolveGatewayPort(cfg);
|
||||
if (!Number.isFinite(port) || port <= 0) {
|
||||
defaultRuntime.error("Invalid port");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
if (opts.force) {
|
||||
try {
|
||||
const { killed, waitedMs, escalatedToSigkill } =
|
||||
await forceFreePortAndWait(port, {
|
||||
timeoutMs: 2000,
|
||||
intervalMs: 100,
|
||||
sigtermTimeoutMs: 700,
|
||||
});
|
||||
if (killed.length === 0) {
|
||||
gatewayLog.info(`force: no listeners on port ${port}`);
|
||||
} else {
|
||||
for (const proc of killed) {
|
||||
gatewayLog.info(
|
||||
`force: killed pid ${proc.pid}${proc.command ? ` (${proc.command})` : ""} on port ${port}`,
|
||||
);
|
||||
}
|
||||
if (escalatedToSigkill) {
|
||||
gatewayLog.info(
|
||||
`force: escalated to SIGKILL while freeing port ${port}`,
|
||||
);
|
||||
}
|
||||
if (waitedMs > 0) {
|
||||
gatewayLog.info(
|
||||
`force: waited ${waitedMs}ms for port ${port} to free`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`Force: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (opts.token) {
|
||||
const token = toOptionString(opts.token);
|
||||
if (token) process.env.CLAWDBOT_GATEWAY_TOKEN = token;
|
||||
}
|
||||
const authModeRaw = toOptionString(opts.auth);
|
||||
const authMode: GatewayAuthMode | null =
|
||||
authModeRaw === "token" || authModeRaw === "password" ? authModeRaw : null;
|
||||
if (authModeRaw && !authMode) {
|
||||
defaultRuntime.error('Invalid --auth (use "token" or "password")');
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const tailscaleRaw = toOptionString(opts.tailscale);
|
||||
const tailscaleMode =
|
||||
tailscaleRaw === "off" ||
|
||||
tailscaleRaw === "serve" ||
|
||||
tailscaleRaw === "funnel"
|
||||
? tailscaleRaw
|
||||
: null;
|
||||
if (tailscaleRaw && !tailscaleMode) {
|
||||
defaultRuntime.error(
|
||||
'Invalid --tailscale (use "off", "serve", or "funnel")',
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const passwordRaw = toOptionString(opts.password);
|
||||
const tokenRaw = toOptionString(opts.token);
|
||||
|
||||
const configExists = fs.existsSync(CONFIG_PATH_CLAWDBOT);
|
||||
const mode = cfg.gateway?.mode;
|
||||
if (!opts.allowUnconfigured && mode !== "local") {
|
||||
if (!configExists) {
|
||||
defaultRuntime.error(
|
||||
"Missing config. Run `clawdbot setup` or set gateway.mode=local (or pass --allow-unconfigured).",
|
||||
);
|
||||
} else {
|
||||
defaultRuntime.error(
|
||||
`Gateway start blocked: set gateway.mode=local (current: ${mode ?? "unset"}) or pass --allow-unconfigured.`,
|
||||
);
|
||||
}
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const bindRaw = toOptionString(opts.bind) ?? cfg.gateway?.bind ?? "loopback";
|
||||
const bind =
|
||||
bindRaw === "loopback" ||
|
||||
bindRaw === "lan" ||
|
||||
bindRaw === "auto" ||
|
||||
bindRaw === "custom"
|
||||
? bindRaw
|
||||
: null;
|
||||
if (!bind) {
|
||||
defaultRuntime.error(
|
||||
'Invalid --bind (use "loopback", "lan", "auto", or "custom")',
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = await readConfigFileSnapshot().catch(() => null);
|
||||
const miskeys = extractGatewayMiskeys(snapshot?.parsed);
|
||||
const authConfig = {
|
||||
...cfg.gateway?.auth,
|
||||
...(authMode ? { mode: authMode } : {}),
|
||||
...(passwordRaw ? { password: passwordRaw } : {}),
|
||||
...(tokenRaw ? { token: tokenRaw } : {}),
|
||||
};
|
||||
const resolvedAuth = resolveGatewayAuth({
|
||||
authConfig,
|
||||
env: process.env,
|
||||
tailscaleMode: tailscaleMode ?? cfg.gateway?.tailscale?.mode ?? "off",
|
||||
});
|
||||
const resolvedAuthMode = resolvedAuth.mode;
|
||||
const tokenValue = resolvedAuth.token;
|
||||
const passwordValue = resolvedAuth.password;
|
||||
const authHints: string[] = [];
|
||||
if (miskeys.hasGatewayToken) {
|
||||
authHints.push(
|
||||
'Found "gateway.token" in config. Use "gateway.auth.token" instead.',
|
||||
);
|
||||
}
|
||||
if (miskeys.hasRemoteToken) {
|
||||
authHints.push(
|
||||
'"gateway.remote.token" is for remote CLI calls; it does not enable local gateway auth.',
|
||||
);
|
||||
}
|
||||
if (resolvedAuthMode === "token" && !tokenValue) {
|
||||
defaultRuntime.error(
|
||||
[
|
||||
"Gateway auth is set to token, but no token is configured.",
|
||||
"Set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN), or pass --token.",
|
||||
...authHints,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (resolvedAuthMode === "password" && !passwordValue) {
|
||||
defaultRuntime.error(
|
||||
[
|
||||
"Gateway auth is set to password, but no password is configured.",
|
||||
"Set gateway.auth.password (or CLAWDBOT_GATEWAY_PASSWORD), or pass --password.",
|
||||
...authHints,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (bind !== "loopback" && resolvedAuthMode === "none") {
|
||||
defaultRuntime.error(
|
||||
[
|
||||
`Refusing to bind gateway to ${bind} without auth.`,
|
||||
"Set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) or pass --token.",
|
||||
...authHints,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await runGatewayLoop({
|
||||
runtime: defaultRuntime,
|
||||
start: async () =>
|
||||
await startGatewayServer(port, {
|
||||
bind,
|
||||
auth:
|
||||
authMode || passwordRaw || tokenRaw || authModeRaw
|
||||
? {
|
||||
mode: authMode ?? undefined,
|
||||
token: tokenRaw,
|
||||
password: passwordRaw,
|
||||
}
|
||||
: undefined,
|
||||
tailscale:
|
||||
tailscaleMode || opts.tailscaleResetOnExit
|
||||
? {
|
||||
mode: tailscaleMode ?? undefined,
|
||||
resetOnExit: Boolean(opts.tailscaleResetOnExit),
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof GatewayLockError ||
|
||||
(err &&
|
||||
typeof err === "object" &&
|
||||
(err as { name?: string }).name === "GatewayLockError")
|
||||
) {
|
||||
const errMessage = describeUnknownError(err);
|
||||
defaultRuntime.error(
|
||||
`Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: clawdbot daemon stop`,
|
||||
);
|
||||
try {
|
||||
const diagnostics = await inspectPortUsage(port);
|
||||
if (diagnostics.status === "busy") {
|
||||
for (const line of formatPortDiagnostics(diagnostics)) {
|
||||
defaultRuntime.error(line);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore diagnostics failures
|
||||
}
|
||||
await maybeExplainGatewayServiceStop();
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
defaultRuntime.error(`Gateway failed to start: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export function addGatewayRunCommand(
|
||||
cmd: Command,
|
||||
params: GatewayRunParams = {},
|
||||
): Command {
|
||||
return cmd
|
||||
.option("--port <port>", "Port for the gateway WebSocket")
|
||||
.option(
|
||||
"--bind <mode>",
|
||||
'Bind mode ("loopback"|"tailnet"|"lan"|"auto"). Defaults to config gateway.bind (or loopback).',
|
||||
)
|
||||
.option(
|
||||
"--token <token>",
|
||||
"Shared token required in connect.params.auth.token (default: CLAWDBOT_GATEWAY_TOKEN env if set)",
|
||||
)
|
||||
.option("--auth <mode>", 'Gateway auth mode ("token"|"password")')
|
||||
.option("--password <password>", "Password for auth mode=password")
|
||||
.option(
|
||||
"--tailscale <mode>",
|
||||
'Tailscale exposure mode ("off"|"serve"|"funnel")',
|
||||
)
|
||||
.option(
|
||||
"--tailscale-reset-on-exit",
|
||||
"Reset Tailscale serve/funnel configuration on shutdown",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"--allow-unconfigured",
|
||||
"Allow gateway start without gateway.mode=local in config",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"--dev",
|
||||
"Create a dev config + workspace if missing (no BOOTSTRAP.md)",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"--reset",
|
||||
"Reset dev config + credentials + sessions + workspace (requires --dev)",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"--force",
|
||||
"Kill any existing listener on the target port before starting",
|
||||
false,
|
||||
)
|
||||
.option("--verbose", "Verbose logging to stdout/stderr", false)
|
||||
.option(
|
||||
"--claude-cli-logs",
|
||||
"Only show claude-cli logs in the console (includes stdout/stderr)",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"--ws-log <style>",
|
||||
'WebSocket log style ("auto"|"full"|"compact")',
|
||||
"auto",
|
||||
)
|
||||
.option("--compact", 'Alias for "--ws-log compact"', false)
|
||||
.option("--raw-stream", "Log raw model stream events to jsonl", false)
|
||||
.option("--raw-stream-path <path>", "Raw stream jsonl path")
|
||||
.action(async (opts) => {
|
||||
await runGatewayCommand(opts, params);
|
||||
});
|
||||
}
|
||||
113
src/cli/gateway-cli/shared.ts
Normal file
113
src/cli/gateway-cli/shared.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
resolveGatewayLaunchAgentLabel,
|
||||
resolveGatewaySystemdServiceName,
|
||||
resolveGatewayWindowsTaskName,
|
||||
} from "../../daemon/constants.js";
|
||||
import { resolveGatewayService } from "../../daemon/service.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
|
||||
export function parsePort(raw: unknown): number | null {
|
||||
if (raw === undefined || raw === null) return null;
|
||||
const value =
|
||||
typeof raw === "string"
|
||||
? raw
|
||||
: typeof raw === "number" || typeof raw === "bigint"
|
||||
? raw.toString()
|
||||
: null;
|
||||
if (value === null) return null;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export const toOptionString = (value: unknown): string | undefined => {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "bigint")
|
||||
return value.toString();
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export function describeUnknownError(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === "string") return err;
|
||||
if (typeof err === "number" || typeof err === "bigint") return err.toString();
|
||||
if (typeof err === "boolean") return err ? "true" : "false";
|
||||
if (err && typeof err === "object") {
|
||||
if ("message" in err && typeof err.message === "string") {
|
||||
return err.message;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(err);
|
||||
} catch {
|
||||
return "Unknown error";
|
||||
}
|
||||
}
|
||||
return "Unknown error";
|
||||
}
|
||||
|
||||
export function extractGatewayMiskeys(parsed: unknown): {
|
||||
hasGatewayToken: boolean;
|
||||
hasRemoteToken: boolean;
|
||||
} {
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return { hasGatewayToken: false, hasRemoteToken: false };
|
||||
}
|
||||
const gateway = (parsed as Record<string, unknown>).gateway;
|
||||
if (!gateway || typeof gateway !== "object") {
|
||||
return { hasGatewayToken: false, hasRemoteToken: false };
|
||||
}
|
||||
const hasGatewayToken = "token" in (gateway as Record<string, unknown>);
|
||||
const remote = (gateway as Record<string, unknown>).remote;
|
||||
const hasRemoteToken =
|
||||
remote && typeof remote === "object"
|
||||
? "token" in (remote as Record<string, unknown>)
|
||||
: false;
|
||||
return { hasGatewayToken, hasRemoteToken };
|
||||
}
|
||||
|
||||
export function renderGatewayServiceStopHints(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string[] {
|
||||
const profile = env.CLAWDBOT_PROFILE;
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
return [
|
||||
"Tip: clawdbot daemon stop",
|
||||
`Or: launchctl bootout gui/$UID/${resolveGatewayLaunchAgentLabel(profile)}`,
|
||||
];
|
||||
case "linux":
|
||||
return [
|
||||
"Tip: clawdbot daemon stop",
|
||||
`Or: systemctl --user stop ${resolveGatewaySystemdServiceName(profile)}.service`,
|
||||
];
|
||||
case "win32":
|
||||
return [
|
||||
"Tip: clawdbot daemon stop",
|
||||
`Or: schtasks /End /TN "${resolveGatewayWindowsTaskName(profile)}"`,
|
||||
];
|
||||
default:
|
||||
return ["Tip: clawdbot daemon stop"];
|
||||
}
|
||||
}
|
||||
|
||||
export async function maybeExplainGatewayServiceStop() {
|
||||
const service = resolveGatewayService();
|
||||
let loaded: boolean | null = null;
|
||||
try {
|
||||
loaded = await service.isLoaded({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
});
|
||||
} catch {
|
||||
loaded = null;
|
||||
}
|
||||
if (loaded === false) return;
|
||||
defaultRuntime.error(
|
||||
loaded
|
||||
? `Gateway service appears ${service.loadedText}. Stop it first.`
|
||||
: "Gateway service status unknown; if supervised, stop it first.",
|
||||
);
|
||||
for (const hint of renderGatewayServiceStopHints()) {
|
||||
defaultRuntime.error(hint);
|
||||
}
|
||||
}
|
||||
1606
src/cli/nodes-cli.ts
1606
src/cli/nodes-cli.ts
File diff suppressed because it is too large
Load Diff
89
src/cli/nodes-cli/a2ui-jsonl.ts
Normal file
89
src/cli/nodes-cli/a2ui-jsonl.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
const A2UI_ACTION_KEYS = [
|
||||
"beginRendering",
|
||||
"surfaceUpdate",
|
||||
"dataModelUpdate",
|
||||
"deleteSurface",
|
||||
"createSurface",
|
||||
] as const;
|
||||
|
||||
export type A2UIVersion = "v0.8" | "v0.9";
|
||||
|
||||
export function buildA2UITextJsonl(text: string) {
|
||||
const surfaceId = "main";
|
||||
const rootId = "root";
|
||||
const textId = "text";
|
||||
const payloads = [
|
||||
{
|
||||
surfaceUpdate: {
|
||||
surfaceId,
|
||||
components: [
|
||||
{
|
||||
id: rootId,
|
||||
component: { Column: { children: { explicitList: [textId] } } },
|
||||
},
|
||||
{
|
||||
id: textId,
|
||||
component: {
|
||||
Text: { text: { literalString: text }, usageHint: "body" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ beginRendering: { surfaceId, root: rootId } },
|
||||
];
|
||||
return payloads.map((payload) => JSON.stringify(payload)).join("\n");
|
||||
}
|
||||
|
||||
export function validateA2UIJsonl(jsonl: string) {
|
||||
const lines = jsonl.split(/\r?\n/);
|
||||
const errors: string[] = [];
|
||||
let sawV08 = false;
|
||||
let sawV09 = false;
|
||||
let messageCount = 0;
|
||||
|
||||
lines.forEach((line, idx) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
messageCount += 1;
|
||||
let obj: unknown;
|
||||
try {
|
||||
obj = JSON.parse(trimmed) as unknown;
|
||||
} catch (err) {
|
||||
errors.push(`line ${idx + 1}: ${String(err)}`);
|
||||
return;
|
||||
}
|
||||
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
||||
errors.push(`line ${idx + 1}: expected JSON object`);
|
||||
return;
|
||||
}
|
||||
const record = obj as Record<string, unknown>;
|
||||
const actionKeys = A2UI_ACTION_KEYS.filter((key) => key in record);
|
||||
if (actionKeys.length !== 1) {
|
||||
errors.push(
|
||||
`line ${idx + 1}: expected exactly one action key (${A2UI_ACTION_KEYS.join(
|
||||
", ",
|
||||
)})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (actionKeys[0] === "createSurface") {
|
||||
sawV09 = true;
|
||||
} else {
|
||||
sawV08 = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (messageCount === 0) {
|
||||
errors.push("no JSONL messages found");
|
||||
}
|
||||
if (sawV08 && sawV09) {
|
||||
errors.push("mixed A2UI v0.8 and v0.9 messages in one file");
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Invalid A2UI JSONL:\n- ${errors.join("\n- ")}`);
|
||||
}
|
||||
|
||||
const version: A2UIVersion = sawV09 ? "v0.9" : "v0.8";
|
||||
return { version, messageCount };
|
||||
}
|
||||
50
src/cli/nodes-cli/format.ts
Normal file
50
src/cli/nodes-cli/format.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type {
|
||||
NodeListNode,
|
||||
PairedNode,
|
||||
PairingList,
|
||||
PendingRequest,
|
||||
} from "./types.js";
|
||||
|
||||
export function formatAge(msAgo: number) {
|
||||
const s = Math.max(0, Math.floor(msAgo / 1000));
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h`;
|
||||
const d = Math.floor(h / 24);
|
||||
return `${d}d`;
|
||||
}
|
||||
|
||||
export function parsePairingList(value: unknown): PairingList {
|
||||
const obj =
|
||||
typeof value === "object" && value !== null
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
const pending = Array.isArray(obj.pending)
|
||||
? (obj.pending as PendingRequest[])
|
||||
: [];
|
||||
const paired = Array.isArray(obj.paired) ? (obj.paired as PairedNode[]) : [];
|
||||
return { pending, paired };
|
||||
}
|
||||
|
||||
export function parseNodeList(value: unknown): NodeListNode[] {
|
||||
const obj =
|
||||
typeof value === "object" && value !== null
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
return Array.isArray(obj.nodes) ? (obj.nodes as NodeListNode[]) : [];
|
||||
}
|
||||
|
||||
export function formatPermissions(raw: unknown) {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const entries = Object.entries(raw as Record<string, unknown>)
|
||||
.map(([key, value]) => [String(key).trim(), value === true] as const)
|
||||
.filter(([key]) => key.length > 0)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]));
|
||||
if (entries.length === 0) return null;
|
||||
const parts = entries.map(
|
||||
([key, granted]) => `${key}=${granted ? "yes" : "no"}`,
|
||||
);
|
||||
return `[${parts.join(", ")}]`;
|
||||
}
|
||||
285
src/cli/nodes-cli/register.camera.ts
Normal file
285
src/cli/nodes-cli/register.camera.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import type { Command } from "commander";
|
||||
import { randomIdempotencyKey } from "../../gateway/call.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import {
|
||||
type CameraFacing,
|
||||
cameraTempPath,
|
||||
parseCameraClipPayload,
|
||||
parseCameraSnapPayload,
|
||||
writeBase64ToFile,
|
||||
} from "../nodes-camera.js";
|
||||
import { parseDurationMs } from "../parse-duration.js";
|
||||
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||
import type { NodesRpcOpts } from "./types.js";
|
||||
|
||||
const parseFacing = (value: string): CameraFacing => {
|
||||
const v = String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (v === "front" || v === "back") return v;
|
||||
throw new Error(`invalid facing: ${value} (expected front|back)`);
|
||||
};
|
||||
|
||||
export function registerNodesCameraCommands(nodes: Command) {
|
||||
const camera = nodes
|
||||
.command("camera")
|
||||
.description("Capture camera media from a paired node");
|
||||
|
||||
nodesCallOpts(
|
||||
camera
|
||||
.command("list")
|
||||
.description("List available cameras on a node")
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.action(async (opts: NodesRpcOpts) => {
|
||||
try {
|
||||
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
||||
const raw = (await callGatewayCli("node.invoke", opts, {
|
||||
nodeId,
|
||||
command: "camera.list",
|
||||
params: {},
|
||||
idempotencyKey: randomIdempotencyKey(),
|
||||
})) as unknown;
|
||||
|
||||
const res =
|
||||
typeof raw === "object" && raw !== null
|
||||
? (raw as { payload?: unknown })
|
||||
: {};
|
||||
const payload =
|
||||
typeof res.payload === "object" && res.payload !== null
|
||||
? (res.payload as { devices?: unknown })
|
||||
: {};
|
||||
const devices = Array.isArray(payload.devices) ? payload.devices : [];
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(devices, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (devices.length === 0) {
|
||||
defaultRuntime.log("No cameras reported.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const device of devices) {
|
||||
const id = typeof device.id === "string" ? device.id : "";
|
||||
const name =
|
||||
typeof device.name === "string" ? device.name : "Unknown Camera";
|
||||
const position =
|
||||
typeof device.position === "string"
|
||||
? device.position
|
||||
: "unspecified";
|
||||
defaultRuntime.log(`${name} (${position})${id ? ` — ${id}` : ""}`);
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes camera list failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
|
||||
nodesCallOpts(
|
||||
camera
|
||||
.command("snap")
|
||||
.description("Capture a photo from a node camera (prints MEDIA:<path>)")
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.option("--facing <front|back|both>", "Camera facing", "both")
|
||||
.option("--device-id <id>", "Camera device id (from nodes camera list)")
|
||||
.option("--max-width <px>", "Max width in px (optional)")
|
||||
.option("--quality <0-1>", "JPEG quality (default 0.9)")
|
||||
.option(
|
||||
"--delay-ms <ms>",
|
||||
"Delay before capture in ms (macOS default 2000)",
|
||||
)
|
||||
.option(
|
||||
"--invoke-timeout <ms>",
|
||||
"Node invoke timeout in ms (default 20000)",
|
||||
"20000",
|
||||
)
|
||||
.action(async (opts: NodesRpcOpts) => {
|
||||
try {
|
||||
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
||||
const facingOpt = String(opts.facing ?? "both")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const facings: CameraFacing[] =
|
||||
facingOpt === "both"
|
||||
? ["front", "back"]
|
||||
: facingOpt === "front" || facingOpt === "back"
|
||||
? [facingOpt]
|
||||
: (() => {
|
||||
throw new Error(
|
||||
`invalid facing: ${String(opts.facing)} (expected front|back|both)`,
|
||||
);
|
||||
})();
|
||||
|
||||
const maxWidth = opts.maxWidth
|
||||
? Number.parseInt(String(opts.maxWidth), 10)
|
||||
: undefined;
|
||||
const quality = opts.quality
|
||||
? Number.parseFloat(String(opts.quality))
|
||||
: undefined;
|
||||
const delayMs = opts.delayMs
|
||||
? Number.parseInt(String(opts.delayMs), 10)
|
||||
: undefined;
|
||||
const deviceId = opts.deviceId
|
||||
? String(opts.deviceId).trim()
|
||||
: undefined;
|
||||
const timeoutMs = opts.invokeTimeout
|
||||
? Number.parseInt(String(opts.invokeTimeout), 10)
|
||||
: undefined;
|
||||
|
||||
const results: Array<{
|
||||
facing: CameraFacing;
|
||||
path: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}> = [];
|
||||
|
||||
for (const facing of facings) {
|
||||
const invokeParams: Record<string, unknown> = {
|
||||
nodeId,
|
||||
command: "camera.snap",
|
||||
params: {
|
||||
facing,
|
||||
maxWidth: Number.isFinite(maxWidth) ? maxWidth : undefined,
|
||||
quality: Number.isFinite(quality) ? quality : undefined,
|
||||
format: "jpg",
|
||||
delayMs: Number.isFinite(delayMs) ? delayMs : undefined,
|
||||
deviceId: deviceId || undefined,
|
||||
},
|
||||
idempotencyKey: randomIdempotencyKey(),
|
||||
};
|
||||
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) {
|
||||
invokeParams.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
const raw = (await callGatewayCli(
|
||||
"node.invoke",
|
||||
opts,
|
||||
invokeParams,
|
||||
)) as unknown;
|
||||
const res =
|
||||
typeof raw === "object" && raw !== null
|
||||
? (raw as { payload?: unknown })
|
||||
: {};
|
||||
const payload = parseCameraSnapPayload(res.payload);
|
||||
const filePath = cameraTempPath({
|
||||
kind: "snap",
|
||||
facing,
|
||||
ext: payload.format === "jpeg" ? "jpg" : payload.format,
|
||||
});
|
||||
await writeBase64ToFile(filePath, payload.base64);
|
||||
results.push({
|
||||
facing,
|
||||
path: filePath,
|
||||
width: payload.width,
|
||||
height: payload.height,
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify({ files: results }, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(results.map((r) => `MEDIA:${r.path}`).join("\n"));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes camera snap failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
|
||||
nodesCallOpts(
|
||||
camera
|
||||
.command("clip")
|
||||
.description(
|
||||
"Capture a short video clip from a node camera (prints MEDIA:<path>)",
|
||||
)
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.option("--facing <front|back>", "Camera facing", "front")
|
||||
.option("--device-id <id>", "Camera device id (from nodes camera list)")
|
||||
.option(
|
||||
"--duration <ms|10s|1m>",
|
||||
"Duration (default 3000ms; supports ms/s/m, e.g. 10s)",
|
||||
"3000",
|
||||
)
|
||||
.option("--no-audio", "Disable audio capture")
|
||||
.option(
|
||||
"--invoke-timeout <ms>",
|
||||
"Node invoke timeout in ms (default 90000)",
|
||||
"90000",
|
||||
)
|
||||
.action(async (opts: NodesRpcOpts & { audio?: boolean }) => {
|
||||
try {
|
||||
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
||||
const facing = parseFacing(String(opts.facing ?? "front"));
|
||||
const durationMs = parseDurationMs(String(opts.duration ?? "3000"));
|
||||
const includeAudio = opts.audio !== false;
|
||||
const timeoutMs = opts.invokeTimeout
|
||||
? Number.parseInt(String(opts.invokeTimeout), 10)
|
||||
: undefined;
|
||||
const deviceId = opts.deviceId
|
||||
? String(opts.deviceId).trim()
|
||||
: undefined;
|
||||
|
||||
const invokeParams: Record<string, unknown> = {
|
||||
nodeId,
|
||||
command: "camera.clip",
|
||||
params: {
|
||||
facing,
|
||||
durationMs: Number.isFinite(durationMs) ? durationMs : undefined,
|
||||
includeAudio,
|
||||
format: "mp4",
|
||||
deviceId: deviceId || undefined,
|
||||
},
|
||||
idempotencyKey: randomIdempotencyKey(),
|
||||
};
|
||||
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) {
|
||||
invokeParams.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
const raw = (await callGatewayCli(
|
||||
"node.invoke",
|
||||
opts,
|
||||
invokeParams,
|
||||
)) as unknown;
|
||||
const res =
|
||||
typeof raw === "object" && raw !== null
|
||||
? (raw as { payload?: unknown })
|
||||
: {};
|
||||
const payload = parseCameraClipPayload(res.payload);
|
||||
const filePath = cameraTempPath({
|
||||
kind: "clip",
|
||||
facing,
|
||||
ext: payload.format,
|
||||
});
|
||||
await writeBase64ToFile(filePath, payload.base64);
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
file: {
|
||||
facing,
|
||||
path: filePath,
|
||||
durationMs: payload.durationMs,
|
||||
hasAudio: payload.hasAudio,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`MEDIA:${filePath}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes camera clip failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
{ timeoutMs: 90_000 },
|
||||
);
|
||||
}
|
||||
293
src/cli/nodes-cli/register.canvas.ts
Normal file
293
src/cli/nodes-cli/register.canvas.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { Command } from "commander";
|
||||
import { randomIdempotencyKey } from "../../gateway/call.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { writeBase64ToFile } from "../nodes-camera.js";
|
||||
import {
|
||||
canvasSnapshotTempPath,
|
||||
parseCanvasSnapshotPayload,
|
||||
} from "../nodes-canvas.js";
|
||||
import { parseTimeoutMs } from "../nodes-run.js";
|
||||
import { buildA2UITextJsonl, validateA2UIJsonl } from "./a2ui-jsonl.js";
|
||||
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||
import type { NodesRpcOpts } from "./types.js";
|
||||
|
||||
async function invokeCanvas(
|
||||
opts: NodesRpcOpts,
|
||||
command: string,
|
||||
params?: Record<string, unknown>,
|
||||
) {
|
||||
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
||||
const invokeParams: Record<string, unknown> = {
|
||||
nodeId,
|
||||
command,
|
||||
params,
|
||||
idempotencyKey: randomIdempotencyKey(),
|
||||
};
|
||||
const timeoutMs = parseTimeoutMs(opts.invokeTimeout);
|
||||
if (typeof timeoutMs === "number") {
|
||||
invokeParams.timeoutMs = timeoutMs;
|
||||
}
|
||||
return await callGatewayCli("node.invoke", opts, invokeParams);
|
||||
}
|
||||
|
||||
export function registerNodesCanvasCommands(nodes: Command) {
|
||||
const canvas = nodes
|
||||
.command("canvas")
|
||||
.description("Capture or render canvas content from a paired node");
|
||||
|
||||
nodesCallOpts(
|
||||
canvas
|
||||
.command("snapshot")
|
||||
.description("Capture a canvas snapshot (prints MEDIA:<path>)")
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.option("--format <png|jpg|jpeg>", "Image format", "jpg")
|
||||
.option("--max-width <px>", "Max width in px (optional)")
|
||||
.option("--quality <0-1>", "JPEG quality (optional)")
|
||||
.option(
|
||||
"--invoke-timeout <ms>",
|
||||
"Node invoke timeout in ms (default 20000)",
|
||||
"20000",
|
||||
)
|
||||
.action(async (opts: NodesRpcOpts) => {
|
||||
try {
|
||||
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
||||
const formatOpt = String(opts.format ?? "jpg")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const formatForParams =
|
||||
formatOpt === "jpg"
|
||||
? "jpeg"
|
||||
: formatOpt === "jpeg"
|
||||
? "jpeg"
|
||||
: "png";
|
||||
if (formatForParams !== "png" && formatForParams !== "jpeg") {
|
||||
throw new Error(
|
||||
`invalid format: ${String(opts.format)} (expected png|jpg|jpeg)`,
|
||||
);
|
||||
}
|
||||
|
||||
const maxWidth = opts.maxWidth
|
||||
? Number.parseInt(String(opts.maxWidth), 10)
|
||||
: undefined;
|
||||
const quality = opts.quality
|
||||
? Number.parseFloat(String(opts.quality))
|
||||
: undefined;
|
||||
const timeoutMs = opts.invokeTimeout
|
||||
? Number.parseInt(String(opts.invokeTimeout), 10)
|
||||
: undefined;
|
||||
|
||||
const invokeParams: Record<string, unknown> = {
|
||||
nodeId,
|
||||
command: "canvas.snapshot",
|
||||
params: {
|
||||
format: formatForParams,
|
||||
maxWidth: Number.isFinite(maxWidth) ? maxWidth : undefined,
|
||||
quality: Number.isFinite(quality) ? quality : undefined,
|
||||
},
|
||||
idempotencyKey: randomIdempotencyKey(),
|
||||
};
|
||||
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) {
|
||||
invokeParams.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
const raw = (await callGatewayCli(
|
||||
"node.invoke",
|
||||
opts,
|
||||
invokeParams,
|
||||
)) as unknown;
|
||||
const res =
|
||||
typeof raw === "object" && raw !== null
|
||||
? (raw as { payload?: unknown })
|
||||
: {};
|
||||
const payload = parseCanvasSnapshotPayload(res.payload);
|
||||
const filePath = canvasSnapshotTempPath({
|
||||
ext: payload.format === "jpeg" ? "jpg" : payload.format,
|
||||
});
|
||||
await writeBase64ToFile(filePath, payload.base64);
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(
|
||||
JSON.stringify(
|
||||
{ file: { path: filePath, format: payload.format } },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`MEDIA:${filePath}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes canvas snapshot failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
|
||||
nodesCallOpts(
|
||||
canvas
|
||||
.command("present")
|
||||
.description("Show the canvas (optionally with a target URL/path)")
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.option("--target <urlOrPath>", "Target URL/path (optional)")
|
||||
.option("--x <px>", "Placement x coordinate")
|
||||
.option("--y <px>", "Placement y coordinate")
|
||||
.option("--width <px>", "Placement width")
|
||||
.option("--height <px>", "Placement height")
|
||||
.option("--invoke-timeout <ms>", "Node invoke timeout in ms")
|
||||
.action(async (opts: NodesRpcOpts) => {
|
||||
try {
|
||||
const placement = {
|
||||
x: opts.x ? Number.parseFloat(opts.x) : undefined,
|
||||
y: opts.y ? Number.parseFloat(opts.y) : undefined,
|
||||
width: opts.width ? Number.parseFloat(opts.width) : undefined,
|
||||
height: opts.height ? Number.parseFloat(opts.height) : undefined,
|
||||
};
|
||||
const params: Record<string, unknown> = {};
|
||||
if (opts.target) params.url = String(opts.target);
|
||||
if (
|
||||
Number.isFinite(placement.x) ||
|
||||
Number.isFinite(placement.y) ||
|
||||
Number.isFinite(placement.width) ||
|
||||
Number.isFinite(placement.height)
|
||||
) {
|
||||
params.placement = placement;
|
||||
}
|
||||
await invokeCanvas(opts, "canvas.present", params);
|
||||
if (!opts.json) defaultRuntime.log("canvas present ok");
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes canvas present failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
nodesCallOpts(
|
||||
canvas
|
||||
.command("hide")
|
||||
.description("Hide the canvas")
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.option("--invoke-timeout <ms>", "Node invoke timeout in ms")
|
||||
.action(async (opts: NodesRpcOpts) => {
|
||||
try {
|
||||
await invokeCanvas(opts, "canvas.hide", undefined);
|
||||
if (!opts.json) defaultRuntime.log("canvas hide ok");
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes canvas hide failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
nodesCallOpts(
|
||||
canvas
|
||||
.command("navigate")
|
||||
.description("Navigate the canvas to a URL")
|
||||
.argument("<url>", "Target URL/path")
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.option("--invoke-timeout <ms>", "Node invoke timeout in ms")
|
||||
.action(async (url: string, opts: NodesRpcOpts) => {
|
||||
try {
|
||||
await invokeCanvas(opts, "canvas.navigate", { url });
|
||||
if (!opts.json) defaultRuntime.log("canvas navigate ok");
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes canvas navigate failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
nodesCallOpts(
|
||||
canvas
|
||||
.command("eval")
|
||||
.description("Evaluate JavaScript in the canvas")
|
||||
.argument("[js]", "JavaScript to evaluate")
|
||||
.option("--js <code>", "JavaScript to evaluate")
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.option("--invoke-timeout <ms>", "Node invoke timeout in ms")
|
||||
.action(async (jsArg: string | undefined, opts: NodesRpcOpts) => {
|
||||
try {
|
||||
const js = opts.js ?? jsArg;
|
||||
if (!js) throw new Error("missing --js or <js>");
|
||||
const raw = await invokeCanvas(opts, "canvas.eval", {
|
||||
javaScript: js,
|
||||
});
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(raw, null, 2));
|
||||
return;
|
||||
}
|
||||
const payload =
|
||||
typeof raw === "object" && raw !== null
|
||||
? (raw as { payload?: { result?: string } }).payload
|
||||
: undefined;
|
||||
if (payload?.result) defaultRuntime.log(payload.result);
|
||||
else defaultRuntime.log("canvas eval ok");
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes canvas eval failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const a2ui = canvas
|
||||
.command("a2ui")
|
||||
.description("Render A2UI content on the canvas");
|
||||
|
||||
nodesCallOpts(
|
||||
a2ui
|
||||
.command("push")
|
||||
.description("Push A2UI JSONL to the canvas")
|
||||
.option("--jsonl <path>", "Path to JSONL payload")
|
||||
.option("--text <text>", "Render a quick A2UI text payload")
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.option("--invoke-timeout <ms>", "Node invoke timeout in ms")
|
||||
.action(async (opts: NodesRpcOpts) => {
|
||||
try {
|
||||
const hasJsonl = Boolean(opts.jsonl);
|
||||
const hasText = typeof opts.text === "string";
|
||||
if (hasJsonl === hasText) {
|
||||
throw new Error("provide exactly one of --jsonl or --text");
|
||||
}
|
||||
|
||||
const jsonl = hasText
|
||||
? buildA2UITextJsonl(String(opts.text ?? ""))
|
||||
: await fs.readFile(String(opts.jsonl), "utf8");
|
||||
const { version, messageCount } = validateA2UIJsonl(jsonl);
|
||||
if (version === "v0.9") {
|
||||
throw new Error(
|
||||
"Detected A2UI v0.9 JSONL (createSurface). Clawdbot currently supports v0.8 only.",
|
||||
);
|
||||
}
|
||||
await invokeCanvas(opts, "canvas.a2ui.pushJSONL", { jsonl });
|
||||
if (!opts.json) {
|
||||
defaultRuntime.log(
|
||||
`canvas a2ui push ok (v0.8, ${messageCount} message${messageCount === 1 ? "" : "s"})`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes canvas a2ui push failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
nodesCallOpts(
|
||||
a2ui
|
||||
.command("reset")
|
||||
.description("Reset A2UI renderer state")
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.option("--invoke-timeout <ms>", "Node invoke timeout in ms")
|
||||
.action(async (opts: NodesRpcOpts) => {
|
||||
try {
|
||||
await invokeCanvas(opts, "canvas.a2ui.reset", undefined);
|
||||
if (!opts.json) defaultRuntime.log("canvas a2ui reset ok");
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
`nodes canvas a2ui reset failed: ${String(err)}`,
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
163
src/cli/nodes-cli/register.invoke.ts
Normal file
163
src/cli/nodes-cli/register.invoke.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { Command } from "commander";
|
||||
import { randomIdempotencyKey } from "../../gateway/call.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { parseEnvPairs, parseTimeoutMs } from "../nodes-run.js";
|
||||
import {
|
||||
callGatewayCli,
|
||||
nodesCallOpts,
|
||||
resolveNodeId,
|
||||
unauthorizedHintForMessage,
|
||||
} from "./rpc.js";
|
||||
import type { NodesRpcOpts } from "./types.js";
|
||||
|
||||
export function registerNodesInvokeCommands(nodes: Command) {
|
||||
nodesCallOpts(
|
||||
nodes
|
||||
.command("invoke")
|
||||
.description("Invoke a command on a paired node")
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.requiredOption("--command <command>", "Command (e.g. canvas.eval)")
|
||||
.option("--params <json>", "JSON object string for params", "{}")
|
||||
.option(
|
||||
"--invoke-timeout <ms>",
|
||||
"Node invoke timeout in ms (default 15000)",
|
||||
"15000",
|
||||
)
|
||||
.option("--idempotency-key <key>", "Idempotency key (optional)")
|
||||
.action(async (opts: NodesRpcOpts) => {
|
||||
try {
|
||||
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
||||
const command = String(opts.command ?? "").trim();
|
||||
if (!nodeId || !command) {
|
||||
defaultRuntime.error("--node and --command required");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const params = JSON.parse(String(opts.params ?? "{}")) as unknown;
|
||||
const timeoutMs = opts.invokeTimeout
|
||||
? Number.parseInt(String(opts.invokeTimeout), 10)
|
||||
: undefined;
|
||||
|
||||
const invokeParams: Record<string, unknown> = {
|
||||
nodeId,
|
||||
command,
|
||||
params,
|
||||
idempotencyKey: String(
|
||||
opts.idempotencyKey ?? randomIdempotencyKey(),
|
||||
),
|
||||
};
|
||||
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) {
|
||||
invokeParams.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
const result = await callGatewayCli(
|
||||
"node.invoke",
|
||||
opts,
|
||||
invokeParams,
|
||||
);
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes invoke failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
{ timeoutMs: 30_000 },
|
||||
);
|
||||
|
||||
nodesCallOpts(
|
||||
nodes
|
||||
.command("run")
|
||||
.description("Run a shell command on a node (mac only)")
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.option("--cwd <path>", "Working directory")
|
||||
.option(
|
||||
"--env <key=val>",
|
||||
"Environment override (repeatable)",
|
||||
(value: string, prev: string[] = []) => [...prev, value],
|
||||
)
|
||||
.option("--command-timeout <ms>", "Command timeout (ms)")
|
||||
.option("--needs-screen-recording", "Require screen recording permission")
|
||||
.option(
|
||||
"--invoke-timeout <ms>",
|
||||
"Node invoke timeout in ms (default 30000)",
|
||||
"30000",
|
||||
)
|
||||
.argument("<command...>", "Command and args")
|
||||
.action(async (command: string[], opts: NodesRpcOpts) => {
|
||||
try {
|
||||
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
||||
if (!Array.isArray(command) || command.length === 0) {
|
||||
throw new Error("command required");
|
||||
}
|
||||
const env = parseEnvPairs(opts.env);
|
||||
const timeoutMs = parseTimeoutMs(opts.commandTimeout);
|
||||
const invokeTimeout = parseTimeoutMs(opts.invokeTimeout);
|
||||
|
||||
const invokeParams: Record<string, unknown> = {
|
||||
nodeId,
|
||||
command: "system.run",
|
||||
params: {
|
||||
command,
|
||||
cwd: opts.cwd,
|
||||
env,
|
||||
timeoutMs,
|
||||
needsScreenRecording: opts.needsScreenRecording === true,
|
||||
},
|
||||
idempotencyKey: String(
|
||||
opts.idempotencyKey ?? randomIdempotencyKey(),
|
||||
),
|
||||
};
|
||||
if (invokeTimeout !== undefined) {
|
||||
invokeParams.timeoutMs = invokeTimeout;
|
||||
}
|
||||
|
||||
const result = (await callGatewayCli(
|
||||
"node.invoke",
|
||||
opts,
|
||||
invokeParams,
|
||||
)) as unknown;
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload =
|
||||
typeof result === "object" && result !== null
|
||||
? (result as { payload?: Record<string, unknown> }).payload
|
||||
: undefined;
|
||||
|
||||
const stdout =
|
||||
typeof payload?.stdout === "string" ? payload.stdout : "";
|
||||
const stderr =
|
||||
typeof payload?.stderr === "string" ? payload.stderr : "";
|
||||
const exitCode =
|
||||
typeof payload?.exitCode === "number" ? payload.exitCode : null;
|
||||
const timedOut = payload?.timedOut === true;
|
||||
const success = payload?.success === true;
|
||||
|
||||
if (stdout) process.stdout.write(stdout);
|
||||
if (stderr) process.stderr.write(stderr);
|
||||
if (timedOut) {
|
||||
defaultRuntime.error("run timed out");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (exitCode !== null && exitCode !== 0) {
|
||||
const hint = unauthorizedHintForMessage(`${stderr}\n${stdout}`);
|
||||
if (hint) defaultRuntime.error(hint);
|
||||
}
|
||||
if (exitCode !== null && exitCode !== 0 && !success) {
|
||||
defaultRuntime.error(`run exit ${exitCode}`);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes run failed: ${String(err)}`);
|
||||
const hint = unauthorizedHintForMessage(String(err));
|
||||
if (hint) defaultRuntime.error(hint);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
{ timeoutMs: 35_000 },
|
||||
);
|
||||
}
|
||||
104
src/cli/nodes-cli/register.location.ts
Normal file
104
src/cli/nodes-cli/register.location.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { Command } from "commander";
|
||||
import { randomIdempotencyKey } from "../../gateway/call.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||
import type { NodesRpcOpts } from "./types.js";
|
||||
|
||||
export function registerNodesLocationCommands(nodes: Command) {
|
||||
const location = nodes
|
||||
.command("location")
|
||||
.description("Fetch location from a paired node");
|
||||
|
||||
nodesCallOpts(
|
||||
location
|
||||
.command("get")
|
||||
.description("Fetch the current location from a node")
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.option("--max-age <ms>", "Use cached location newer than this (ms)")
|
||||
.option(
|
||||
"--accuracy <coarse|balanced|precise>",
|
||||
"Desired accuracy (default: balanced/precise depending on node setting)",
|
||||
)
|
||||
.option("--location-timeout <ms>", "Location fix timeout (ms)", "10000")
|
||||
.option(
|
||||
"--invoke-timeout <ms>",
|
||||
"Node invoke timeout in ms (default 20000)",
|
||||
"20000",
|
||||
)
|
||||
.action(async (opts: NodesRpcOpts) => {
|
||||
try {
|
||||
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
||||
const maxAgeMs = opts.maxAge
|
||||
? Number.parseInt(String(opts.maxAge), 10)
|
||||
: undefined;
|
||||
const desiredAccuracyRaw =
|
||||
typeof opts.accuracy === "string"
|
||||
? opts.accuracy.trim().toLowerCase()
|
||||
: undefined;
|
||||
const desiredAccuracy =
|
||||
desiredAccuracyRaw === "coarse" ||
|
||||
desiredAccuracyRaw === "balanced" ||
|
||||
desiredAccuracyRaw === "precise"
|
||||
? desiredAccuracyRaw
|
||||
: undefined;
|
||||
const timeoutMs = opts.locationTimeout
|
||||
? Number.parseInt(String(opts.locationTimeout), 10)
|
||||
: undefined;
|
||||
const invokeTimeoutMs = opts.invokeTimeout
|
||||
? Number.parseInt(String(opts.invokeTimeout), 10)
|
||||
: undefined;
|
||||
|
||||
const invokeParams: Record<string, unknown> = {
|
||||
nodeId,
|
||||
command: "location.get",
|
||||
params: {
|
||||
maxAgeMs: Number.isFinite(maxAgeMs) ? maxAgeMs : undefined,
|
||||
desiredAccuracy,
|
||||
timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : undefined,
|
||||
},
|
||||
idempotencyKey: randomIdempotencyKey(),
|
||||
};
|
||||
if (
|
||||
typeof invokeTimeoutMs === "number" &&
|
||||
Number.isFinite(invokeTimeoutMs)
|
||||
) {
|
||||
invokeParams.timeoutMs = invokeTimeoutMs;
|
||||
}
|
||||
|
||||
const raw = (await callGatewayCli(
|
||||
"node.invoke",
|
||||
opts,
|
||||
invokeParams,
|
||||
)) as unknown;
|
||||
const res =
|
||||
typeof raw === "object" && raw !== null
|
||||
? (raw as { payload?: unknown })
|
||||
: {};
|
||||
const payload =
|
||||
res.payload && typeof res.payload === "object"
|
||||
? (res.payload as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(payload, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const lat = payload.lat;
|
||||
const lon = payload.lon;
|
||||
const acc = payload.accuracyMeters;
|
||||
if (typeof lat === "number" && typeof lon === "number") {
|
||||
const accText =
|
||||
typeof acc === "number" ? ` ±${acc.toFixed(1)}m` : "";
|
||||
defaultRuntime.log(`${lat},${lon}${accText}`);
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(JSON.stringify(payload));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes location get failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
{ timeoutMs: 30_000 },
|
||||
);
|
||||
}
|
||||
74
src/cli/nodes-cli/register.notify.ts
Normal file
74
src/cli/nodes-cli/register.notify.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { Command } from "commander";
|
||||
import { randomIdempotencyKey } from "../../gateway/call.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||
import type { NodesRpcOpts } from "./types.js";
|
||||
|
||||
export function registerNodesNotifyCommand(nodes: Command) {
|
||||
nodesCallOpts(
|
||||
nodes
|
||||
.command("notify")
|
||||
.description("Send a local notification on a node (mac only)")
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.option("--title <text>", "Notification title")
|
||||
.option("--body <text>", "Notification body")
|
||||
.option("--sound <name>", "Notification sound")
|
||||
.option(
|
||||
"--priority <passive|active|timeSensitive>",
|
||||
"Notification priority",
|
||||
)
|
||||
.option("--delivery <system|overlay|auto>", "Delivery mode", "system")
|
||||
.option(
|
||||
"--invoke-timeout <ms>",
|
||||
"Node invoke timeout in ms (default 15000)",
|
||||
"15000",
|
||||
)
|
||||
.action(async (opts: NodesRpcOpts) => {
|
||||
try {
|
||||
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
||||
const title = String(opts.title ?? "").trim();
|
||||
const body = String(opts.body ?? "").trim();
|
||||
if (!title && !body) {
|
||||
throw new Error("missing --title or --body");
|
||||
}
|
||||
const invokeTimeout = opts.invokeTimeout
|
||||
? Number.parseInt(String(opts.invokeTimeout), 10)
|
||||
: undefined;
|
||||
const invokeParams: Record<string, unknown> = {
|
||||
nodeId,
|
||||
command: "system.notify",
|
||||
params: {
|
||||
title,
|
||||
body,
|
||||
sound: opts.sound,
|
||||
priority: opts.priority,
|
||||
delivery: opts.delivery,
|
||||
},
|
||||
idempotencyKey: String(
|
||||
opts.idempotencyKey ?? randomIdempotencyKey(),
|
||||
),
|
||||
};
|
||||
if (
|
||||
typeof invokeTimeout === "number" &&
|
||||
Number.isFinite(invokeTimeout)
|
||||
) {
|
||||
invokeParams.timeoutMs = invokeTimeout;
|
||||
}
|
||||
|
||||
const result = await callGatewayCli(
|
||||
"node.invoke",
|
||||
opts,
|
||||
invokeParams,
|
||||
);
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log("notify ok");
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes notify failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
111
src/cli/nodes-cli/register.pairing.ts
Normal file
111
src/cli/nodes-cli/register.pairing.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { Command } from "commander";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { formatAge, parsePairingList } from "./format.js";
|
||||
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||
import type { NodesRpcOpts } from "./types.js";
|
||||
|
||||
export function registerNodesPairingCommands(nodes: Command) {
|
||||
nodesCallOpts(
|
||||
nodes
|
||||
.command("pending")
|
||||
.description("List pending pairing requests")
|
||||
.action(async (opts: NodesRpcOpts) => {
|
||||
try {
|
||||
const result = (await callGatewayCli(
|
||||
"node.pair.list",
|
||||
opts,
|
||||
{},
|
||||
)) as unknown;
|
||||
const { pending } = parsePairingList(result);
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(pending, null, 2));
|
||||
return;
|
||||
}
|
||||
if (pending.length === 0) {
|
||||
defaultRuntime.log("No pending pairing requests.");
|
||||
return;
|
||||
}
|
||||
for (const r of pending) {
|
||||
const name = r.displayName || r.nodeId;
|
||||
const repair = r.isRepair ? " (repair)" : "";
|
||||
const ip = r.remoteIp ? ` · ${r.remoteIp}` : "";
|
||||
const age =
|
||||
typeof r.ts === "number"
|
||||
? ` · ${formatAge(Date.now() - r.ts)} ago`
|
||||
: "";
|
||||
defaultRuntime.log(`- ${r.requestId}: ${name}${repair}${ip}${age}`);
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes pending failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
nodesCallOpts(
|
||||
nodes
|
||||
.command("approve")
|
||||
.description("Approve a pending pairing request")
|
||||
.argument("<requestId>", "Pending request id")
|
||||
.action(async (requestId: string, opts: NodesRpcOpts) => {
|
||||
try {
|
||||
const result = await callGatewayCli("node.pair.approve", opts, {
|
||||
requestId,
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes approve failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
nodesCallOpts(
|
||||
nodes
|
||||
.command("reject")
|
||||
.description("Reject a pending pairing request")
|
||||
.argument("<requestId>", "Pending request id")
|
||||
.action(async (requestId: string, opts: NodesRpcOpts) => {
|
||||
try {
|
||||
const result = await callGatewayCli("node.pair.reject", opts, {
|
||||
requestId,
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes reject failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
nodesCallOpts(
|
||||
nodes
|
||||
.command("rename")
|
||||
.description("Rename a paired node (display name override)")
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.requiredOption("--name <displayName>", "New display name")
|
||||
.action(async (opts: NodesRpcOpts) => {
|
||||
try {
|
||||
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
||||
const name = String(opts.name ?? "").trim();
|
||||
if (!nodeId || !name) {
|
||||
defaultRuntime.error("--node and --name required");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const result = await callGatewayCli("node.rename", opts, {
|
||||
nodeId,
|
||||
displayName: name,
|
||||
});
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`node rename ok: ${nodeId} -> ${name}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes rename failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
106
src/cli/nodes-cli/register.screen.ts
Normal file
106
src/cli/nodes-cli/register.screen.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { Command } from "commander";
|
||||
import { randomIdempotencyKey } from "../../gateway/call.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import {
|
||||
parseScreenRecordPayload,
|
||||
screenRecordTempPath,
|
||||
writeScreenRecordToFile,
|
||||
} from "../nodes-screen.js";
|
||||
import { parseDurationMs } from "../parse-duration.js";
|
||||
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||
import type { NodesRpcOpts } from "./types.js";
|
||||
|
||||
export function registerNodesScreenCommands(nodes: Command) {
|
||||
const screen = nodes
|
||||
.command("screen")
|
||||
.description("Capture screen recordings from a paired node");
|
||||
|
||||
nodesCallOpts(
|
||||
screen
|
||||
.command("record")
|
||||
.description(
|
||||
"Capture a short screen recording from a node (prints MEDIA:<path>)",
|
||||
)
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.option("--screen <index>", "Screen index (0 = primary)", "0")
|
||||
.option("--duration <ms|10s>", "Clip duration (ms or 10s)", "10000")
|
||||
.option("--fps <fps>", "Frames per second", "10")
|
||||
.option("--no-audio", "Disable microphone audio capture")
|
||||
.option("--out <path>", "Output path")
|
||||
.option(
|
||||
"--invoke-timeout <ms>",
|
||||
"Node invoke timeout in ms (default 120000)",
|
||||
"120000",
|
||||
)
|
||||
.action(async (opts: NodesRpcOpts & { out?: string }) => {
|
||||
try {
|
||||
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
||||
const durationMs = parseDurationMs(opts.duration ?? "");
|
||||
const screenIndex = Number.parseInt(String(opts.screen ?? "0"), 10);
|
||||
const fps = Number.parseFloat(String(opts.fps ?? "10"));
|
||||
const timeoutMs = opts.invokeTimeout
|
||||
? Number.parseInt(String(opts.invokeTimeout), 10)
|
||||
: undefined;
|
||||
|
||||
const invokeParams: Record<string, unknown> = {
|
||||
nodeId,
|
||||
command: "screen.record",
|
||||
params: {
|
||||
durationMs: Number.isFinite(durationMs) ? durationMs : undefined,
|
||||
screenIndex: Number.isFinite(screenIndex)
|
||||
? screenIndex
|
||||
: undefined,
|
||||
fps: Number.isFinite(fps) ? fps : undefined,
|
||||
format: "mp4",
|
||||
includeAudio: opts.audio !== false,
|
||||
},
|
||||
idempotencyKey: randomIdempotencyKey(),
|
||||
};
|
||||
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) {
|
||||
invokeParams.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
const raw = (await callGatewayCli(
|
||||
"node.invoke",
|
||||
opts,
|
||||
invokeParams,
|
||||
)) as unknown;
|
||||
const res =
|
||||
typeof raw === "object" && raw !== null
|
||||
? (raw as { payload?: unknown })
|
||||
: {};
|
||||
const parsed = parseScreenRecordPayload(res.payload);
|
||||
const filePath =
|
||||
opts.out ?? screenRecordTempPath({ ext: parsed.format || "mp4" });
|
||||
const written = await writeScreenRecordToFile(
|
||||
filePath,
|
||||
parsed.base64,
|
||||
);
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
file: {
|
||||
path: written.path,
|
||||
durationMs: parsed.durationMs,
|
||||
fps: parsed.fps,
|
||||
screenIndex: parsed.screenIndex,
|
||||
hasAudio: parsed.hasAudio,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`MEDIA:${written.path}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes screen record failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
{ timeoutMs: 180_000 },
|
||||
);
|
||||
}
|
||||
168
src/cli/nodes-cli/register.status.ts
Normal file
168
src/cli/nodes-cli/register.status.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { Command } from "commander";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import {
|
||||
formatAge,
|
||||
formatPermissions,
|
||||
parseNodeList,
|
||||
parsePairingList,
|
||||
} from "./format.js";
|
||||
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||
import type { NodesRpcOpts } from "./types.js";
|
||||
|
||||
export function registerNodesStatusCommands(nodes: Command) {
|
||||
nodesCallOpts(
|
||||
nodes
|
||||
.command("status")
|
||||
.description("List known nodes with connection status and capabilities")
|
||||
.action(async (opts: NodesRpcOpts) => {
|
||||
try {
|
||||
const result = (await callGatewayCli(
|
||||
"node.list",
|
||||
opts,
|
||||
{},
|
||||
)) as unknown;
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
const nodes = parseNodeList(result);
|
||||
const pairedCount = nodes.filter((n) => Boolean(n.paired)).length;
|
||||
const connectedCount = nodes.filter((n) =>
|
||||
Boolean(n.connected),
|
||||
).length;
|
||||
defaultRuntime.log(
|
||||
`Known: ${nodes.length} · Paired: ${pairedCount} · Connected: ${connectedCount}`,
|
||||
);
|
||||
for (const n of nodes) {
|
||||
const name = n.displayName || n.nodeId;
|
||||
const ip = n.remoteIp ? ` · ${n.remoteIp}` : "";
|
||||
const device = n.deviceFamily ? ` · device: ${n.deviceFamily}` : "";
|
||||
const hw = n.modelIdentifier ? ` · hw: ${n.modelIdentifier}` : "";
|
||||
const perms = formatPermissions(n.permissions);
|
||||
const permsText = perms ? ` · perms: ${perms}` : "";
|
||||
const caps =
|
||||
Array.isArray(n.caps) && n.caps.length > 0
|
||||
? `[${n.caps.map(String).filter(Boolean).sort().join(",")}]`
|
||||
: Array.isArray(n.caps)
|
||||
? "[]"
|
||||
: "?";
|
||||
const pairing = n.paired ? "paired" : "unpaired";
|
||||
defaultRuntime.log(
|
||||
`- ${name} · ${n.nodeId}${ip}${device}${hw}${permsText} · ${pairing} · ${n.connected ? "connected" : "disconnected"} · caps: ${caps}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes status failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
nodesCallOpts(
|
||||
nodes
|
||||
.command("describe")
|
||||
.description("Describe a node (capabilities + supported invoke commands)")
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.action(async (opts: NodesRpcOpts) => {
|
||||
try {
|
||||
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
||||
const result = (await callGatewayCli("node.describe", opts, {
|
||||
nodeId,
|
||||
})) as unknown;
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const obj =
|
||||
typeof result === "object" && result !== null
|
||||
? (result as Record<string, unknown>)
|
||||
: {};
|
||||
const displayName =
|
||||
typeof obj.displayName === "string" ? obj.displayName : nodeId;
|
||||
const connected = Boolean(obj.connected);
|
||||
const caps = Array.isArray(obj.caps)
|
||||
? obj.caps.map(String).filter(Boolean).sort()
|
||||
: null;
|
||||
const commands = Array.isArray(obj.commands)
|
||||
? obj.commands.map(String).filter(Boolean).sort()
|
||||
: [];
|
||||
const perms = formatPermissions(obj.permissions);
|
||||
const family =
|
||||
typeof obj.deviceFamily === "string" ? obj.deviceFamily : null;
|
||||
const model =
|
||||
typeof obj.modelIdentifier === "string"
|
||||
? obj.modelIdentifier
|
||||
: null;
|
||||
const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : null;
|
||||
|
||||
const parts: string[] = ["Node:", displayName, nodeId];
|
||||
if (ip) parts.push(ip);
|
||||
if (family) parts.push(`device: ${family}`);
|
||||
if (model) parts.push(`hw: ${model}`);
|
||||
if (perms) parts.push(`perms: ${perms}`);
|
||||
parts.push(connected ? "connected" : "disconnected");
|
||||
parts.push(`caps: ${caps ? `[${caps.join(",")}]` : "?"}`);
|
||||
defaultRuntime.log(parts.join(" · "));
|
||||
defaultRuntime.log("Commands:");
|
||||
if (commands.length === 0) {
|
||||
defaultRuntime.log("- (none reported)");
|
||||
return;
|
||||
}
|
||||
for (const c of commands) defaultRuntime.log(`- ${c}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes describe failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
nodesCallOpts(
|
||||
nodes
|
||||
.command("list")
|
||||
.description("List pending and paired nodes")
|
||||
.action(async (opts: NodesRpcOpts) => {
|
||||
try {
|
||||
const result = (await callGatewayCli(
|
||||
"node.pair.list",
|
||||
opts,
|
||||
{},
|
||||
)) as unknown;
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
const { pending, paired } = parsePairingList(result);
|
||||
defaultRuntime.log(
|
||||
`Pending: ${pending.length} · Paired: ${paired.length}`,
|
||||
);
|
||||
if (pending.length > 0) {
|
||||
defaultRuntime.log("\nPending:");
|
||||
for (const r of pending) {
|
||||
const name = r.displayName || r.nodeId;
|
||||
const repair = r.isRepair ? " (repair)" : "";
|
||||
const ip = r.remoteIp ? ` · ${r.remoteIp}` : "";
|
||||
const age =
|
||||
typeof r.ts === "number"
|
||||
? ` · ${formatAge(Date.now() - r.ts)} ago`
|
||||
: "";
|
||||
defaultRuntime.log(
|
||||
`- ${r.requestId}: ${name}${repair}${ip}${age}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (paired.length > 0) {
|
||||
defaultRuntime.log("\nPaired:");
|
||||
for (const n of paired) {
|
||||
const name = n.displayName || n.nodeId;
|
||||
const ip = n.remoteIp ? ` · ${n.remoteIp}` : "";
|
||||
defaultRuntime.log(`- ${n.nodeId}: ${name}${ip}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes list failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
34
src/cli/nodes-cli/register.ts
Normal file
34
src/cli/nodes-cli/register.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Command } from "commander";
|
||||
import { formatDocsLink } from "../../terminal/links.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import { registerNodesCameraCommands } from "./register.camera.js";
|
||||
import { registerNodesCanvasCommands } from "./register.canvas.js";
|
||||
import { registerNodesInvokeCommands } from "./register.invoke.js";
|
||||
import { registerNodesLocationCommands } from "./register.location.js";
|
||||
import { registerNodesNotifyCommand } from "./register.notify.js";
|
||||
import { registerNodesPairingCommands } from "./register.pairing.js";
|
||||
import { registerNodesScreenCommands } from "./register.screen.js";
|
||||
import { registerNodesStatusCommands } from "./register.status.js";
|
||||
|
||||
export function registerNodesCli(program: Command) {
|
||||
const nodes = program
|
||||
.command("nodes")
|
||||
.description("Manage gateway-owned node pairing")
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.muted("Docs:")} ${formatDocsLink(
|
||||
"/nodes",
|
||||
"docs.clawd.bot/nodes",
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
registerNodesStatusCommands(nodes);
|
||||
registerNodesPairingCommands(nodes);
|
||||
registerNodesInvokeCommands(nodes);
|
||||
registerNodesNotifyCommand(nodes);
|
||||
registerNodesCanvasCommands(nodes);
|
||||
registerNodesCameraCommands(nodes);
|
||||
registerNodesScreenCommands(nodes);
|
||||
registerNodesLocationCommands(nodes);
|
||||
}
|
||||
118
src/cli/nodes-cli/rpc.ts
Normal file
118
src/cli/nodes-cli/rpc.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { Command } from "commander";
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
import {
|
||||
GATEWAY_CLIENT_MODES,
|
||||
GATEWAY_CLIENT_NAMES,
|
||||
} from "../../utils/message-channel.js";
|
||||
import { withProgress } from "../progress.js";
|
||||
import { parseNodeList, parsePairingList } from "./format.js";
|
||||
import type { NodeListNode, NodesRpcOpts } from "./types.js";
|
||||
|
||||
export const nodesCallOpts = (
|
||||
cmd: Command,
|
||||
defaults?: { timeoutMs?: number },
|
||||
) =>
|
||||
cmd
|
||||
.option(
|
||||
"--url <url>",
|
||||
"Gateway WebSocket URL (defaults to gateway.remote.url when configured)",
|
||||
)
|
||||
.option("--token <token>", "Gateway token (if required)")
|
||||
.option(
|
||||
"--timeout <ms>",
|
||||
"Timeout in ms",
|
||||
String(defaults?.timeoutMs ?? 10_000),
|
||||
)
|
||||
.option("--json", "Output JSON", false);
|
||||
|
||||
export const callGatewayCli = async (
|
||||
method: string,
|
||||
opts: NodesRpcOpts,
|
||||
params?: unknown,
|
||||
) =>
|
||||
withProgress(
|
||||
{
|
||||
label: `Nodes ${method}`,
|
||||
indeterminate: true,
|
||||
enabled: opts.json !== true,
|
||||
},
|
||||
async () =>
|
||||
await callGateway({
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
method,
|
||||
params,
|
||||
timeoutMs: Number(opts.timeout ?? 10_000),
|
||||
clientName: GATEWAY_CLIENT_NAMES.CLI,
|
||||
mode: GATEWAY_CLIENT_MODES.CLI,
|
||||
}),
|
||||
);
|
||||
|
||||
export function unauthorizedHintForMessage(message: string): string | null {
|
||||
const haystack = message.toLowerCase();
|
||||
if (
|
||||
haystack.includes("unauthorizedclient") ||
|
||||
haystack.includes("bridge client is not authorized") ||
|
||||
haystack.includes("unsigned bridge clients are not allowed")
|
||||
) {
|
||||
return [
|
||||
"peekaboo bridge rejected the client.",
|
||||
"sign the peekaboo CLI (TeamID Y5PE65HELJ) or launch the host with",
|
||||
"PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1 for local dev.",
|
||||
].join(" ");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeNodeKey(value: string) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+/, "")
|
||||
.replace(/-+$/, "");
|
||||
}
|
||||
|
||||
export async function resolveNodeId(opts: NodesRpcOpts, query: string) {
|
||||
const q = String(query ?? "").trim();
|
||||
if (!q) throw new Error("node required");
|
||||
|
||||
let nodes: NodeListNode[] = [];
|
||||
try {
|
||||
const res = (await callGatewayCli("node.list", opts, {})) as unknown;
|
||||
nodes = parseNodeList(res);
|
||||
} catch {
|
||||
const res = (await callGatewayCli("node.pair.list", opts, {})) as unknown;
|
||||
const { paired } = parsePairingList(res);
|
||||
nodes = paired.map((n) => ({
|
||||
nodeId: n.nodeId,
|
||||
displayName: n.displayName,
|
||||
platform: n.platform,
|
||||
version: n.version,
|
||||
remoteIp: n.remoteIp,
|
||||
}));
|
||||
}
|
||||
|
||||
const qNorm = normalizeNodeKey(q);
|
||||
const matches = nodes.filter((n) => {
|
||||
if (n.nodeId === q) return true;
|
||||
if (typeof n.remoteIp === "string" && n.remoteIp === q) return true;
|
||||
const name = typeof n.displayName === "string" ? n.displayName : "";
|
||||
if (name && normalizeNodeKey(name) === qNorm) return true;
|
||||
if (q.length >= 6 && n.nodeId.startsWith(q)) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (matches.length === 1) return matches[0].nodeId;
|
||||
if (matches.length === 0) {
|
||||
const known = nodes
|
||||
.map((n) => n.displayName || n.remoteIp || n.nodeId)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
throw new Error(`unknown node: ${q}${known ? ` (known: ${known})` : ""}`);
|
||||
}
|
||||
throw new Error(
|
||||
`ambiguous node: ${q} (matches: ${matches
|
||||
.map((n) => n.displayName || n.remoteIp || n.nodeId)
|
||||
.join(", ")})`,
|
||||
);
|
||||
}
|
||||
85
src/cli/nodes-cli/types.ts
Normal file
85
src/cli/nodes-cli/types.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
export type NodesRpcOpts = {
|
||||
url?: string;
|
||||
token?: string;
|
||||
timeout?: string;
|
||||
json?: boolean;
|
||||
node?: string;
|
||||
command?: string;
|
||||
params?: string;
|
||||
invokeTimeout?: string;
|
||||
idempotencyKey?: string;
|
||||
target?: string;
|
||||
x?: string;
|
||||
y?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
js?: string;
|
||||
jsonl?: string;
|
||||
text?: string;
|
||||
cwd?: string;
|
||||
env?: string[];
|
||||
commandTimeout?: string;
|
||||
needsScreenRecording?: boolean;
|
||||
title?: string;
|
||||
body?: string;
|
||||
sound?: string;
|
||||
priority?: string;
|
||||
delivery?: string;
|
||||
name?: string;
|
||||
facing?: string;
|
||||
format?: string;
|
||||
maxWidth?: string;
|
||||
quality?: string;
|
||||
delayMs?: string;
|
||||
deviceId?: string;
|
||||
maxAge?: string;
|
||||
accuracy?: string;
|
||||
locationTimeout?: string;
|
||||
duration?: string;
|
||||
screen?: string;
|
||||
fps?: string;
|
||||
audio?: boolean;
|
||||
};
|
||||
|
||||
export type NodeListNode = {
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
remoteIp?: string;
|
||||
deviceFamily?: string;
|
||||
modelIdentifier?: string;
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
permissions?: Record<string, boolean>;
|
||||
paired?: boolean;
|
||||
connected?: boolean;
|
||||
};
|
||||
|
||||
export type PendingRequest = {
|
||||
requestId: string;
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
remoteIp?: string;
|
||||
isRepair?: boolean;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
export type PairedNode = {
|
||||
nodeId: string;
|
||||
token?: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
remoteIp?: string;
|
||||
permissions?: Record<string, boolean>;
|
||||
createdAtMs?: number;
|
||||
approvedAtMs?: number;
|
||||
};
|
||||
|
||||
export type PairingList = {
|
||||
pending: PendingRequest[];
|
||||
paired: PairedNode[];
|
||||
};
|
||||
253
src/cli/program.nodes-basic.test.ts
Normal file
253
src/cli/program.nodes-basic.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const messageCommand = vi.fn();
|
||||
const statusCommand = vi.fn();
|
||||
const configureCommand = vi.fn();
|
||||
const configureCommandWithSections = vi.fn();
|
||||
const setupCommand = vi.fn();
|
||||
const onboardCommand = vi.fn();
|
||||
const callGateway = vi.fn();
|
||||
const runChannelLogin = vi.fn();
|
||||
const runChannelLogout = vi.fn();
|
||||
const runTui = vi.fn();
|
||||
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock("../commands/message.js", () => ({ messageCommand }));
|
||||
vi.mock("../commands/status.js", () => ({ statusCommand }));
|
||||
vi.mock("../commands/configure.js", () => ({
|
||||
CONFIGURE_WIZARD_SECTIONS: [
|
||||
"workspace",
|
||||
"model",
|
||||
"gateway",
|
||||
"daemon",
|
||||
"channels",
|
||||
"skills",
|
||||
"health",
|
||||
],
|
||||
configureCommand,
|
||||
configureCommandWithSections,
|
||||
}));
|
||||
vi.mock("../commands/setup.js", () => ({ setupCommand }));
|
||||
vi.mock("../commands/onboard.js", () => ({ onboardCommand }));
|
||||
vi.mock("../runtime.js", () => ({ defaultRuntime: runtime }));
|
||||
vi.mock("./channel-auth.js", () => ({ runChannelLogin, runChannelLogout }));
|
||||
vi.mock("../tui/tui.js", () => ({ runTui }));
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway,
|
||||
randomIdempotencyKey: () => "idem-test",
|
||||
}));
|
||||
vi.mock("./deps.js", () => ({ createDefaultDeps: () => ({}) }));
|
||||
|
||||
const { buildProgram } = await import("./program.js");
|
||||
|
||||
describe("cli program (nodes basics)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
runTui.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("runs nodes list and calls node.pair.list", async () => {
|
||||
callGateway.mockResolvedValue({ pending: [], paired: [] });
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(["nodes", "list"], { from: "user" });
|
||||
expect(callGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: "node.pair.list" }),
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalledWith("Pending: 0 · Paired: 0");
|
||||
});
|
||||
|
||||
it("runs nodes status and calls node.list", async () => {
|
||||
callGateway.mockResolvedValue({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS Node",
|
||||
remoteIp: "192.168.0.88",
|
||||
deviceFamily: "iPad",
|
||||
modelIdentifier: "iPad16,6",
|
||||
caps: ["canvas", "camera"],
|
||||
paired: true,
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(["nodes", "status"], { from: "user" });
|
||||
|
||||
expect(callGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: "node.list", params: {} }),
|
||||
);
|
||||
|
||||
const output = runtime.log.mock.calls
|
||||
.map((c) => String(c[0] ?? ""))
|
||||
.join("\n");
|
||||
expect(output).toContain("Known: 1 · Paired: 1 · Connected: 1");
|
||||
expect(output).toContain("iOS Node");
|
||||
expect(output).toContain("device: iPad");
|
||||
expect(output).toContain("hw: iPad16,6");
|
||||
expect(output).toContain("paired");
|
||||
expect(output).toContain("caps: [camera,canvas]");
|
||||
});
|
||||
|
||||
it("runs nodes status and shows unpaired nodes", async () => {
|
||||
callGateway.mockResolvedValue({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "android-node",
|
||||
displayName: "Peter's Tab S10 Ultra",
|
||||
remoteIp: "192.168.0.99",
|
||||
deviceFamily: "Android",
|
||||
modelIdentifier: "samsung SM-X926B",
|
||||
caps: ["canvas", "camera"],
|
||||
paired: false,
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(["nodes", "status"], { from: "user" });
|
||||
|
||||
const output = runtime.log.mock.calls
|
||||
.map((c) => String(c[0] ?? ""))
|
||||
.join("\n");
|
||||
expect(output).toContain("Known: 1 · Paired: 0 · Connected: 1");
|
||||
expect(output).toContain("Peter's Tab S10 Ultra");
|
||||
expect(output).toContain("device: Android");
|
||||
expect(output).toContain("hw: samsung SM-X926B");
|
||||
expect(output).toContain("unpaired");
|
||||
expect(output).toContain("connected");
|
||||
expect(output).toContain("caps: [camera,canvas]");
|
||||
});
|
||||
|
||||
it("runs nodes describe and calls node.describe", async () => {
|
||||
callGateway
|
||||
.mockResolvedValueOnce({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS Node",
|
||||
remoteIp: "192.168.0.88",
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ts: Date.now(),
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS Node",
|
||||
caps: ["canvas", "camera"],
|
||||
commands: ["canvas.eval", "canvas.snapshot", "camera.snap"],
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(["nodes", "describe", "--node", "ios-node"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(callGateway).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ method: "node.list", params: {} }),
|
||||
);
|
||||
expect(callGateway).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "node.describe",
|
||||
params: { nodeId: "ios-node" },
|
||||
}),
|
||||
);
|
||||
|
||||
const out = runtime.log.mock.calls
|
||||
.map((c) => String(c[0] ?? ""))
|
||||
.join("\n");
|
||||
expect(out).toContain("Commands:");
|
||||
expect(out).toContain("canvas.eval");
|
||||
});
|
||||
|
||||
it("runs nodes approve and calls node.pair.approve", async () => {
|
||||
callGateway.mockResolvedValue({
|
||||
requestId: "r1",
|
||||
node: { nodeId: "n1", token: "t1" },
|
||||
});
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(["nodes", "approve", "r1"], { from: "user" });
|
||||
expect(callGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "node.pair.approve",
|
||||
params: { requestId: "r1" },
|
||||
}),
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs nodes invoke and calls node.invoke", async () => {
|
||||
callGateway
|
||||
.mockResolvedValueOnce({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS Node",
|
||||
remoteIp: "192.168.0.88",
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
nodeId: "ios-node",
|
||||
command: "canvas.eval",
|
||||
payload: { result: "ok" },
|
||||
});
|
||||
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"nodes",
|
||||
"invoke",
|
||||
"--node",
|
||||
"ios-node",
|
||||
"--command",
|
||||
"canvas.eval",
|
||||
"--params",
|
||||
'{"javaScript":"1+1"}',
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(callGateway).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ method: "node.list", params: {} }),
|
||||
);
|
||||
expect(callGateway).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "node.invoke",
|
||||
params: {
|
||||
nodeId: "ios-node",
|
||||
command: "canvas.eval",
|
||||
params: { javaScript: "1+1" },
|
||||
timeoutMs: 15000,
|
||||
idempotencyKey: "idem-test",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
449
src/cli/program.nodes-media.test.ts
Normal file
449
src/cli/program.nodes-media.test.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const messageCommand = vi.fn();
|
||||
const statusCommand = vi.fn();
|
||||
const configureCommand = vi.fn();
|
||||
const configureCommandWithSections = vi.fn();
|
||||
const setupCommand = vi.fn();
|
||||
const onboardCommand = vi.fn();
|
||||
const callGateway = vi.fn();
|
||||
const runChannelLogin = vi.fn();
|
||||
const runChannelLogout = vi.fn();
|
||||
const runTui = vi.fn();
|
||||
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock("../commands/message.js", () => ({ messageCommand }));
|
||||
vi.mock("../commands/status.js", () => ({ statusCommand }));
|
||||
vi.mock("../commands/configure.js", () => ({
|
||||
CONFIGURE_WIZARD_SECTIONS: [
|
||||
"workspace",
|
||||
"model",
|
||||
"gateway",
|
||||
"daemon",
|
||||
"channels",
|
||||
"skills",
|
||||
"health",
|
||||
],
|
||||
configureCommand,
|
||||
configureCommandWithSections,
|
||||
}));
|
||||
vi.mock("../commands/setup.js", () => ({ setupCommand }));
|
||||
vi.mock("../commands/onboard.js", () => ({ onboardCommand }));
|
||||
vi.mock("../runtime.js", () => ({ defaultRuntime: runtime }));
|
||||
vi.mock("./channel-auth.js", () => ({ runChannelLogin, runChannelLogout }));
|
||||
vi.mock("../tui/tui.js", () => ({ runTui }));
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway,
|
||||
randomIdempotencyKey: () => "idem-test",
|
||||
}));
|
||||
vi.mock("./deps.js", () => ({ createDefaultDeps: () => ({}) }));
|
||||
|
||||
const { buildProgram } = await import("./program.js");
|
||||
|
||||
describe("cli program (nodes media)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
runTui.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("runs nodes camera snap and prints two MEDIA paths", async () => {
|
||||
callGateway
|
||||
.mockResolvedValueOnce({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS Node",
|
||||
remoteIp: "192.168.0.88",
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
nodeId: "ios-node",
|
||||
command: "camera.snap",
|
||||
payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
nodeId: "ios-node",
|
||||
command: "camera.snap",
|
||||
payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 },
|
||||
});
|
||||
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(
|
||||
["nodes", "camera", "snap", "--node", "ios-node"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(callGateway).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "node.invoke",
|
||||
params: expect.objectContaining({
|
||||
nodeId: "ios-node",
|
||||
command: "camera.snap",
|
||||
timeoutMs: 20000,
|
||||
idempotencyKey: "idem-test",
|
||||
params: expect.objectContaining({ facing: "front", format: "jpg" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(callGateway).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
method: "node.invoke",
|
||||
params: expect.objectContaining({
|
||||
nodeId: "ios-node",
|
||||
command: "camera.snap",
|
||||
timeoutMs: 20000,
|
||||
idempotencyKey: "idem-test",
|
||||
params: expect.objectContaining({ facing: "back", format: "jpg" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const out = String(runtime.log.mock.calls[0]?.[0] ?? "");
|
||||
const mediaPaths = out
|
||||
.split("\n")
|
||||
.filter((l) => l.startsWith("MEDIA:"))
|
||||
.map((l) => l.replace(/^MEDIA:/, ""))
|
||||
.filter(Boolean);
|
||||
expect(mediaPaths).toHaveLength(2);
|
||||
|
||||
try {
|
||||
for (const p of mediaPaths) {
|
||||
await expect(fs.readFile(p, "utf8")).resolves.toBe("hi");
|
||||
}
|
||||
} finally {
|
||||
await Promise.all(mediaPaths.map((p) => fs.unlink(p).catch(() => {})));
|
||||
}
|
||||
});
|
||||
|
||||
it("runs nodes camera clip and prints one MEDIA path", async () => {
|
||||
callGateway
|
||||
.mockResolvedValueOnce({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS Node",
|
||||
remoteIp: "192.168.0.88",
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
nodeId: "ios-node",
|
||||
command: "camera.clip",
|
||||
payload: {
|
||||
format: "mp4",
|
||||
base64: "aGk=",
|
||||
durationMs: 3000,
|
||||
hasAudio: true,
|
||||
},
|
||||
});
|
||||
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(
|
||||
["nodes", "camera", "clip", "--node", "ios-node", "--duration", "3000"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(callGateway).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "node.invoke",
|
||||
params: expect.objectContaining({
|
||||
nodeId: "ios-node",
|
||||
command: "camera.clip",
|
||||
timeoutMs: 90000,
|
||||
idempotencyKey: "idem-test",
|
||||
params: expect.objectContaining({
|
||||
facing: "front",
|
||||
durationMs: 3000,
|
||||
includeAudio: true,
|
||||
format: "mp4",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const out = String(runtime.log.mock.calls[0]?.[0] ?? "");
|
||||
const mediaPath = out.replace(/^MEDIA:/, "").trim();
|
||||
expect(mediaPath).toMatch(/clawdbot-camera-clip-front-.*\.mp4$/);
|
||||
|
||||
try {
|
||||
await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("hi");
|
||||
} finally {
|
||||
await fs.unlink(mediaPath).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("runs nodes camera snap with facing front and passes params", async () => {
|
||||
callGateway
|
||||
.mockResolvedValueOnce({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS Node",
|
||||
remoteIp: "192.168.0.88",
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
nodeId: "ios-node",
|
||||
command: "camera.snap",
|
||||
payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 },
|
||||
});
|
||||
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"nodes",
|
||||
"camera",
|
||||
"snap",
|
||||
"--node",
|
||||
"ios-node",
|
||||
"--facing",
|
||||
"front",
|
||||
"--max-width",
|
||||
"640",
|
||||
"--quality",
|
||||
"0.8",
|
||||
"--delay-ms",
|
||||
"2000",
|
||||
"--device-id",
|
||||
"cam-123",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(callGateway).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "node.invoke",
|
||||
params: expect.objectContaining({
|
||||
nodeId: "ios-node",
|
||||
command: "camera.snap",
|
||||
timeoutMs: 20000,
|
||||
idempotencyKey: "idem-test",
|
||||
params: expect.objectContaining({
|
||||
facing: "front",
|
||||
maxWidth: 640,
|
||||
quality: 0.8,
|
||||
delayMs: 2000,
|
||||
deviceId: "cam-123",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const out = String(runtime.log.mock.calls[0]?.[0] ?? "");
|
||||
const mediaPath = out.replace(/^MEDIA:/, "").trim();
|
||||
|
||||
try {
|
||||
await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("hi");
|
||||
} finally {
|
||||
await fs.unlink(mediaPath).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("runs nodes camera clip with --no-audio", async () => {
|
||||
callGateway
|
||||
.mockResolvedValueOnce({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS Node",
|
||||
remoteIp: "192.168.0.88",
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
nodeId: "ios-node",
|
||||
command: "camera.clip",
|
||||
payload: {
|
||||
format: "mp4",
|
||||
base64: "aGk=",
|
||||
durationMs: 3000,
|
||||
hasAudio: false,
|
||||
},
|
||||
});
|
||||
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"nodes",
|
||||
"camera",
|
||||
"clip",
|
||||
"--node",
|
||||
"ios-node",
|
||||
"--duration",
|
||||
"3000",
|
||||
"--no-audio",
|
||||
"--device-id",
|
||||
"cam-123",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(callGateway).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "node.invoke",
|
||||
params: expect.objectContaining({
|
||||
nodeId: "ios-node",
|
||||
command: "camera.clip",
|
||||
timeoutMs: 90000,
|
||||
idempotencyKey: "idem-test",
|
||||
params: expect.objectContaining({
|
||||
includeAudio: false,
|
||||
deviceId: "cam-123",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const out = String(runtime.log.mock.calls[0]?.[0] ?? "");
|
||||
const mediaPath = out.replace(/^MEDIA:/, "").trim();
|
||||
|
||||
try {
|
||||
await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("hi");
|
||||
} finally {
|
||||
await fs.unlink(mediaPath).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("runs nodes camera clip with human duration (10s)", async () => {
|
||||
callGateway
|
||||
.mockResolvedValueOnce({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS Node",
|
||||
remoteIp: "192.168.0.88",
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
nodeId: "ios-node",
|
||||
command: "camera.clip",
|
||||
payload: {
|
||||
format: "mp4",
|
||||
base64: "aGk=",
|
||||
durationMs: 10_000,
|
||||
hasAudio: true,
|
||||
},
|
||||
});
|
||||
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(
|
||||
["nodes", "camera", "clip", "--node", "ios-node", "--duration", "10s"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(callGateway).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "node.invoke",
|
||||
params: expect.objectContaining({
|
||||
nodeId: "ios-node",
|
||||
command: "camera.clip",
|
||||
params: expect.objectContaining({ durationMs: 10_000 }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("runs nodes canvas snapshot and prints MEDIA path", async () => {
|
||||
callGateway
|
||||
.mockResolvedValueOnce({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS Node",
|
||||
remoteIp: "192.168.0.88",
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
nodeId: "ios-node",
|
||||
command: "canvas.snapshot",
|
||||
payload: { format: "png", base64: "aGk=" },
|
||||
});
|
||||
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(
|
||||
["nodes", "canvas", "snapshot", "--node", "ios-node", "--format", "png"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
const out = String(runtime.log.mock.calls[0]?.[0] ?? "");
|
||||
const mediaPath = out.replace(/^MEDIA:/, "").trim();
|
||||
expect(mediaPath).toMatch(/clawdbot-canvas-snapshot-.*\.png$/);
|
||||
|
||||
try {
|
||||
await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("hi");
|
||||
} finally {
|
||||
await fs.unlink(mediaPath).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("fails nodes camera snap on invalid facing", async () => {
|
||||
callGateway.mockResolvedValueOnce({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS Node",
|
||||
remoteIp: "192.168.0.88",
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const program = buildProgram();
|
||||
runtime.error.mockClear();
|
||||
|
||||
await expect(
|
||||
program.parseAsync(
|
||||
["nodes", "camera", "snap", "--node", "ios-node", "--facing", "nope"],
|
||||
{ from: "user" },
|
||||
),
|
||||
).rejects.toThrow(/exit/i);
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/invalid facing/i),
|
||||
);
|
||||
});
|
||||
});
|
||||
260
src/cli/program.smoke.test.ts
Normal file
260
src/cli/program.smoke.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const messageCommand = vi.fn();
|
||||
const statusCommand = vi.fn();
|
||||
const configureCommand = vi.fn();
|
||||
const configureCommandWithSections = vi.fn();
|
||||
const setupCommand = vi.fn();
|
||||
const onboardCommand = vi.fn();
|
||||
const callGateway = vi.fn();
|
||||
const runChannelLogin = vi.fn();
|
||||
const runChannelLogout = vi.fn();
|
||||
const runTui = vi.fn();
|
||||
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock("../commands/message.js", () => ({ messageCommand }));
|
||||
vi.mock("../commands/status.js", () => ({ statusCommand }));
|
||||
vi.mock("../commands/configure.js", () => ({
|
||||
CONFIGURE_WIZARD_SECTIONS: [
|
||||
"workspace",
|
||||
"model",
|
||||
"gateway",
|
||||
"daemon",
|
||||
"channels",
|
||||
"skills",
|
||||
"health",
|
||||
],
|
||||
configureCommand,
|
||||
configureCommandWithSections,
|
||||
}));
|
||||
vi.mock("../commands/setup.js", () => ({ setupCommand }));
|
||||
vi.mock("../commands/onboard.js", () => ({ onboardCommand }));
|
||||
vi.mock("../runtime.js", () => ({ defaultRuntime: runtime }));
|
||||
vi.mock("./channel-auth.js", () => ({ runChannelLogin, runChannelLogout }));
|
||||
vi.mock("../tui/tui.js", () => ({ runTui }));
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway,
|
||||
randomIdempotencyKey: () => "idem-test",
|
||||
}));
|
||||
vi.mock("./deps.js", () => ({ createDefaultDeps: () => ({}) }));
|
||||
|
||||
const { buildProgram } = await import("./program.js");
|
||||
|
||||
describe("cli program (smoke)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
runTui.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("runs message with required options", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
["message", "send", "--to", "+1", "--message", "hi"],
|
||||
{
|
||||
from: "user",
|
||||
},
|
||||
);
|
||||
expect(messageCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs status command", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["status"], { from: "user" });
|
||||
expect(statusCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs tui without overriding timeout", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["tui"], { from: "user" });
|
||||
expect(runTui).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeoutMs: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("runs tui with explicit timeout override", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["tui", "--timeout-ms", "45000"], {
|
||||
from: "user",
|
||||
});
|
||||
expect(runTui).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeoutMs: 45000 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns and ignores invalid tui timeout override", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["tui", "--timeout-ms", "nope"], { from: "user" });
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
'warning: invalid --timeout-ms "nope"; ignoring',
|
||||
);
|
||||
expect(runTui).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeoutMs: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("runs config alias as configure", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["config"], { from: "user" });
|
||||
expect(configureCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs setup without wizard flags", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["setup"], { from: "user" });
|
||||
expect(setupCommand).toHaveBeenCalled();
|
||||
expect(onboardCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs setup wizard when wizard flags are present", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["setup", "--remote-url", "ws://example"], {
|
||||
from: "user",
|
||||
});
|
||||
expect(onboardCommand).toHaveBeenCalled();
|
||||
expect(setupCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes opencode-zen api key to onboard", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"onboard",
|
||||
"--non-interactive",
|
||||
"--auth-choice",
|
||||
"opencode-zen",
|
||||
"--opencode-zen-api-key",
|
||||
"sk-opencode-zen-test",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(onboardCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nonInteractive: true,
|
||||
authChoice: "opencode-zen",
|
||||
opencodeZenApiKey: "sk-opencode-zen-test",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes openrouter api key to onboard", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"onboard",
|
||||
"--non-interactive",
|
||||
"--auth-choice",
|
||||
"openrouter-api-key",
|
||||
"--openrouter-api-key",
|
||||
"sk-openrouter-test",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(onboardCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nonInteractive: true,
|
||||
authChoice: "openrouter-api-key",
|
||||
openrouterApiKey: "sk-openrouter-test",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes moonshot api key to onboard", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"onboard",
|
||||
"--non-interactive",
|
||||
"--auth-choice",
|
||||
"moonshot-api-key",
|
||||
"--moonshot-api-key",
|
||||
"sk-moonshot-test",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(onboardCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nonInteractive: true,
|
||||
authChoice: "moonshot-api-key",
|
||||
moonshotApiKey: "sk-moonshot-test",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes synthetic api key to onboard", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"onboard",
|
||||
"--non-interactive",
|
||||
"--auth-choice",
|
||||
"synthetic-api-key",
|
||||
"--synthetic-api-key",
|
||||
"sk-synthetic-test",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(onboardCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nonInteractive: true,
|
||||
authChoice: "synthetic-api-key",
|
||||
syntheticApiKey: "sk-synthetic-test",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes zai api key to onboard", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"onboard",
|
||||
"--non-interactive",
|
||||
"--auth-choice",
|
||||
"zai-api-key",
|
||||
"--zai-api-key",
|
||||
"sk-zai-test",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(onboardCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nonInteractive: true,
|
||||
authChoice: "zai-api-key",
|
||||
zaiApiKey: "sk-zai-test",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("runs channels login", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["channels", "login", "--account", "work"], {
|
||||
from: "user",
|
||||
});
|
||||
expect(runChannelLogin).toHaveBeenCalledWith(
|
||||
{ channel: undefined, account: "work", verbose: false },
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("runs channels logout", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["channels", "logout", "--account", "work"], {
|
||||
from: "user",
|
||||
});
|
||||
expect(runChannelLogout).toHaveBeenCalledWith(
|
||||
{ channel: undefined, account: "work" },
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,867 +0,0 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const messageCommand = vi.fn();
|
||||
const statusCommand = vi.fn();
|
||||
const configureCommand = vi.fn();
|
||||
const configureCommandWithSections = vi.fn();
|
||||
const setupCommand = vi.fn();
|
||||
const onboardCommand = vi.fn();
|
||||
const callGateway = vi.fn();
|
||||
const runChannelLogin = vi.fn();
|
||||
const runChannelLogout = vi.fn();
|
||||
const runTui = vi.fn();
|
||||
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock("../commands/message.js", () => ({
|
||||
messageCommand,
|
||||
}));
|
||||
vi.mock("../commands/status.js", () => ({ statusCommand }));
|
||||
vi.mock("../commands/configure.js", () => ({
|
||||
CONFIGURE_WIZARD_SECTIONS: [
|
||||
"workspace",
|
||||
"model",
|
||||
"gateway",
|
||||
"daemon",
|
||||
"channels",
|
||||
"skills",
|
||||
"health",
|
||||
],
|
||||
configureCommand,
|
||||
configureCommandWithSections,
|
||||
}));
|
||||
vi.mock("../commands/setup.js", () => ({ setupCommand }));
|
||||
vi.mock("../commands/onboard.js", () => ({ onboardCommand }));
|
||||
vi.mock("../runtime.js", () => ({ defaultRuntime: runtime }));
|
||||
vi.mock("./channel-auth.js", () => ({
|
||||
runChannelLogin,
|
||||
runChannelLogout,
|
||||
}));
|
||||
vi.mock("../tui/tui.js", () => ({
|
||||
runTui,
|
||||
}));
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway,
|
||||
randomIdempotencyKey: () => "idem-test",
|
||||
}));
|
||||
vi.mock("./deps.js", () => ({
|
||||
createDefaultDeps: () => ({}),
|
||||
}));
|
||||
|
||||
const { buildProgram } = await import("./program.js");
|
||||
|
||||
describe("cli program", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
runTui.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("runs message with required options", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
["message", "send", "--to", "+1", "--message", "hi"],
|
||||
{
|
||||
from: "user",
|
||||
},
|
||||
);
|
||||
expect(messageCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs status command", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["status"], { from: "user" });
|
||||
expect(statusCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs tui without overriding timeout", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["tui"], { from: "user" });
|
||||
expect(runTui).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeoutMs: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("runs tui with explicit timeout override", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["tui", "--timeout-ms", "45000"], {
|
||||
from: "user",
|
||||
});
|
||||
expect(runTui).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeoutMs: 45000 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns and ignores invalid tui timeout override", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["tui", "--timeout-ms", "nope"], {
|
||||
from: "user",
|
||||
});
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
'warning: invalid --timeout-ms "nope"; ignoring',
|
||||
);
|
||||
expect(runTui).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeoutMs: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("runs config alias as configure", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["config"], { from: "user" });
|
||||
expect(configureCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs setup without wizard flags", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["setup"], { from: "user" });
|
||||
expect(setupCommand).toHaveBeenCalled();
|
||||
expect(onboardCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs setup wizard when wizard flags are present", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["setup", "--remote-url", "ws://example"], {
|
||||
from: "user",
|
||||
});
|
||||
expect(onboardCommand).toHaveBeenCalled();
|
||||
expect(setupCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes opencode-zen api key to onboard", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"onboard",
|
||||
"--non-interactive",
|
||||
"--auth-choice",
|
||||
"opencode-zen",
|
||||
"--opencode-zen-api-key",
|
||||
"sk-opencode-zen-test",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(onboardCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nonInteractive: true,
|
||||
authChoice: "opencode-zen",
|
||||
opencodeZenApiKey: "sk-opencode-zen-test",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes openrouter api key to onboard", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"onboard",
|
||||
"--non-interactive",
|
||||
"--auth-choice",
|
||||
"openrouter-api-key",
|
||||
"--openrouter-api-key",
|
||||
"sk-openrouter-test",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(onboardCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nonInteractive: true,
|
||||
authChoice: "openrouter-api-key",
|
||||
openrouterApiKey: "sk-openrouter-test",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes moonshot api key to onboard", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"onboard",
|
||||
"--non-interactive",
|
||||
"--auth-choice",
|
||||
"moonshot-api-key",
|
||||
"--moonshot-api-key",
|
||||
"sk-moonshot-test",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(onboardCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nonInteractive: true,
|
||||
authChoice: "moonshot-api-key",
|
||||
moonshotApiKey: "sk-moonshot-test",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes synthetic api key to onboard", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"onboard",
|
||||
"--non-interactive",
|
||||
"--auth-choice",
|
||||
"synthetic-api-key",
|
||||
"--synthetic-api-key",
|
||||
"sk-synthetic-test",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(onboardCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nonInteractive: true,
|
||||
authChoice: "synthetic-api-key",
|
||||
syntheticApiKey: "sk-synthetic-test",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes zai api key to onboard", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"onboard",
|
||||
"--non-interactive",
|
||||
"--auth-choice",
|
||||
"zai-api-key",
|
||||
"--zai-api-key",
|
||||
"sk-zai-test",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(onboardCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nonInteractive: true,
|
||||
authChoice: "zai-api-key",
|
||||
zaiApiKey: "sk-zai-test",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("runs channels login", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["channels", "login", "--account", "work"], {
|
||||
from: "user",
|
||||
});
|
||||
expect(runChannelLogin).toHaveBeenCalledWith(
|
||||
{ channel: undefined, account: "work", verbose: false },
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("runs channels logout", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["channels", "logout", "--account", "work"], {
|
||||
from: "user",
|
||||
});
|
||||
expect(runChannelLogout).toHaveBeenCalledWith(
|
||||
{ channel: undefined, account: "work" },
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("runs nodes list and calls node.pair.list", async () => {
|
||||
callGateway.mockResolvedValue({ pending: [], paired: [] });
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(["nodes", "list"], { from: "user" });
|
||||
expect(callGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "node.pair.list",
|
||||
}),
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalledWith("Pending: 0 · Paired: 0");
|
||||
});
|
||||
|
||||
it("runs nodes status and calls node.list", async () => {
|
||||
callGateway.mockResolvedValue({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS Node",
|
||||
remoteIp: "192.168.0.88",
|
||||
deviceFamily: "iPad",
|
||||
modelIdentifier: "iPad16,6",
|
||||
caps: ["canvas", "camera"],
|
||||
paired: true,
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(["nodes", "status"], { from: "user" });
|
||||
|
||||
expect(callGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: "node.list", params: {} }),
|
||||
);
|
||||
|
||||
const output = runtime.log.mock.calls
|
||||
.map((c) => String(c[0] ?? ""))
|
||||
.join("\n");
|
||||
expect(output).toContain("Known: 1 · Paired: 1 · Connected: 1");
|
||||
expect(output).toContain("iOS Node");
|
||||
expect(output).toContain("device: iPad");
|
||||
expect(output).toContain("hw: iPad16,6");
|
||||
expect(output).toContain("paired");
|
||||
expect(output).toContain("caps: [camera,canvas]");
|
||||
});
|
||||
|
||||
it("runs nodes status and shows unpaired nodes", async () => {
|
||||
callGateway.mockResolvedValue({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "android-node",
|
||||
displayName: "Peter's Tab S10 Ultra",
|
||||
remoteIp: "192.168.0.99",
|
||||
deviceFamily: "Android",
|
||||
modelIdentifier: "samsung SM-X926B",
|
||||
caps: ["canvas", "camera"],
|
||||
paired: false,
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(["nodes", "status"], { from: "user" });
|
||||
|
||||
const output = runtime.log.mock.calls
|
||||
.map((c) => String(c[0] ?? ""))
|
||||
.join("\n");
|
||||
expect(output).toContain("Known: 1 · Paired: 0 · Connected: 1");
|
||||
expect(output).toContain("Peter's Tab S10 Ultra");
|
||||
expect(output).toContain("device: Android");
|
||||
expect(output).toContain("hw: samsung SM-X926B");
|
||||
expect(output).toContain("unpaired");
|
||||
expect(output).toContain("connected");
|
||||
expect(output).toContain("caps: [camera,canvas]");
|
||||
});
|
||||
|
||||
it("runs nodes describe and calls node.describe", async () => {
|
||||
callGateway
|
||||
.mockResolvedValueOnce({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS Node",
|
||||
remoteIp: "192.168.0.88",
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ts: Date.now(),
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS Node",
|
||||
caps: ["canvas", "camera"],
|
||||
commands: ["canvas.eval", "canvas.snapshot", "camera.snap"],
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(["nodes", "describe", "--node", "ios-node"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(callGateway).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ method: "node.list", params: {} }),
|
||||
);
|
||||
expect(callGateway).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "node.describe",
|
||||
params: { nodeId: "ios-node" },
|
||||
}),
|
||||
);
|
||||
|
||||
const out = runtime.log.mock.calls
|
||||
.map((c) => String(c[0] ?? ""))
|
||||
.join("\n");
|
||||
expect(out).toContain("Commands:");
|
||||
expect(out).toContain("canvas.eval");
|
||||
});
|
||||
|
||||
it("runs nodes approve and calls node.pair.approve", async () => {
|
||||
callGateway.mockResolvedValue({
|
||||
requestId: "r1",
|
||||
node: { nodeId: "n1", token: "t1" },
|
||||
});
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(["nodes", "approve", "r1"], { from: "user" });
|
||||
expect(callGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "node.pair.approve",
|
||||
params: { requestId: "r1" },
|
||||
}),
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs nodes invoke and calls node.invoke", async () => {
|
||||
callGateway
|
||||
.mockResolvedValueOnce({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS Node",
|
||||
remoteIp: "192.168.0.88",
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
nodeId: "ios-node",
|
||||
command: "canvas.eval",
|
||||
payload: { result: "ok" },
|
||||
});
|
||||
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"nodes",
|
||||
"invoke",
|
||||
"--node",
|
||||
"ios-node",
|
||||
"--command",
|
||||
"canvas.eval",
|
||||
"--params",
|
||||
'{"javaScript":"1+1"}',
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(callGateway).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ method: "node.list", params: {} }),
|
||||
);
|
||||
expect(callGateway).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "node.invoke",
|
||||
params: {
|
||||
nodeId: "ios-node",
|
||||
command: "canvas.eval",
|
||||
params: { javaScript: "1+1" },
|
||||
timeoutMs: 15000,
|
||||
idempotencyKey: "idem-test",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs nodes camera snap and prints two MEDIA paths", async () => {
|
||||
callGateway
|
||||
.mockResolvedValueOnce({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS Node",
|
||||
remoteIp: "192.168.0.88",
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
nodeId: "ios-node",
|
||||
command: "camera.snap",
|
||||
payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
nodeId: "ios-node",
|
||||
command: "camera.snap",
|
||||
payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 },
|
||||
});
|
||||
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(
|
||||
["nodes", "camera", "snap", "--node", "ios-node"],
|
||||
{
|
||||
from: "user",
|
||||
},
|
||||
);
|
||||
|
||||
expect(callGateway).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "node.invoke",
|
||||
params: expect.objectContaining({
|
||||
nodeId: "ios-node",
|
||||
command: "camera.snap",
|
||||
timeoutMs: 20000,
|
||||
idempotencyKey: "idem-test",
|
||||
params: expect.objectContaining({ facing: "front", format: "jpg" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(callGateway).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
method: "node.invoke",
|
||||
params: expect.objectContaining({
|
||||
nodeId: "ios-node",
|
||||
command: "camera.snap",
|
||||
timeoutMs: 20000,
|
||||
idempotencyKey: "idem-test",
|
||||
params: expect.objectContaining({ facing: "back", format: "jpg" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const out = String(runtime.log.mock.calls[0]?.[0] ?? "");
|
||||
const mediaPaths = out
|
||||
.split("\n")
|
||||
.filter((l) => l.startsWith("MEDIA:"))
|
||||
.map((l) => l.replace(/^MEDIA:/, ""))
|
||||
.filter(Boolean);
|
||||
expect(mediaPaths).toHaveLength(2);
|
||||
|
||||
try {
|
||||
for (const p of mediaPaths) {
|
||||
await expect(fs.readFile(p, "utf8")).resolves.toBe("hi");
|
||||
}
|
||||
} finally {
|
||||
await Promise.all(mediaPaths.map((p) => fs.unlink(p).catch(() => {})));
|
||||
}
|
||||
});
|
||||
|
||||
it("runs nodes camera clip and prints one MEDIA path", async () => {
|
||||
callGateway
|
||||
.mockResolvedValueOnce({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS Node",
|
||||
remoteIp: "192.168.0.88",
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
nodeId: "ios-node",
|
||||
command: "camera.clip",
|
||||
payload: {
|
||||
format: "mp4",
|
||||
base64: "aGk=",
|
||||
durationMs: 3000,
|
||||
hasAudio: true,
|
||||
},
|
||||
});
|
||||
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(
|
||||
["nodes", "camera", "clip", "--node", "ios-node", "--duration", "3000"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(callGateway).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "node.invoke",
|
||||
params: expect.objectContaining({
|
||||
nodeId: "ios-node",
|
||||
command: "camera.clip",
|
||||
timeoutMs: 90000,
|
||||
idempotencyKey: "idem-test",
|
||||
params: expect.objectContaining({
|
||||
facing: "front",
|
||||
durationMs: 3000,
|
||||
includeAudio: true,
|
||||
format: "mp4",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const out = String(runtime.log.mock.calls[0]?.[0] ?? "");
|
||||
const mediaPath = out.replace(/^MEDIA:/, "").trim();
|
||||
expect(mediaPath).toMatch(/clawdbot-camera-clip-front-.*\.mp4$/);
|
||||
|
||||
try {
|
||||
await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("hi");
|
||||
} finally {
|
||||
await fs.unlink(mediaPath).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("runs nodes camera snap with facing front and passes params", async () => {
|
||||
callGateway
|
||||
.mockResolvedValueOnce({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS Node",
|
||||
remoteIp: "192.168.0.88",
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
nodeId: "ios-node",
|
||||
command: "camera.snap",
|
||||
payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 },
|
||||
});
|
||||
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"nodes",
|
||||
"camera",
|
||||
"snap",
|
||||
"--node",
|
||||
"ios-node",
|
||||
"--facing",
|
||||
"front",
|
||||
"--max-width",
|
||||
"640",
|
||||
"--quality",
|
||||
"0.8",
|
||||
"--delay-ms",
|
||||
"2000",
|
||||
"--device-id",
|
||||
"cam-123",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(callGateway).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "node.invoke",
|
||||
params: expect.objectContaining({
|
||||
nodeId: "ios-node",
|
||||
command: "camera.snap",
|
||||
timeoutMs: 20000,
|
||||
idempotencyKey: "idem-test",
|
||||
params: expect.objectContaining({
|
||||
facing: "front",
|
||||
maxWidth: 640,
|
||||
quality: 0.8,
|
||||
delayMs: 2000,
|
||||
deviceId: "cam-123",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const out = String(runtime.log.mock.calls[0]?.[0] ?? "");
|
||||
const mediaPath = out.replace(/^MEDIA:/, "").trim();
|
||||
|
||||
try {
|
||||
await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("hi");
|
||||
} finally {
|
||||
await fs.unlink(mediaPath).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("runs nodes camera clip with --no-audio", async () => {
|
||||
callGateway
|
||||
.mockResolvedValueOnce({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS Node",
|
||||
remoteIp: "192.168.0.88",
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
nodeId: "ios-node",
|
||||
command: "camera.clip",
|
||||
payload: {
|
||||
format: "mp4",
|
||||
base64: "aGk=",
|
||||
durationMs: 3000,
|
||||
hasAudio: false,
|
||||
},
|
||||
});
|
||||
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"nodes",
|
||||
"camera",
|
||||
"clip",
|
||||
"--node",
|
||||
"ios-node",
|
||||
"--duration",
|
||||
"3000",
|
||||
"--no-audio",
|
||||
"--device-id",
|
||||
"cam-123",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(callGateway).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "node.invoke",
|
||||
params: expect.objectContaining({
|
||||
nodeId: "ios-node",
|
||||
command: "camera.clip",
|
||||
timeoutMs: 90000,
|
||||
idempotencyKey: "idem-test",
|
||||
params: expect.objectContaining({
|
||||
includeAudio: false,
|
||||
deviceId: "cam-123",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const out = String(runtime.log.mock.calls[0]?.[0] ?? "");
|
||||
const mediaPath = out.replace(/^MEDIA:/, "").trim();
|
||||
|
||||
try {
|
||||
await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("hi");
|
||||
} finally {
|
||||
await fs.unlink(mediaPath).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("runs nodes camera clip with human duration (10s)", async () => {
|
||||
callGateway
|
||||
.mockResolvedValueOnce({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS Node",
|
||||
remoteIp: "192.168.0.88",
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
nodeId: "ios-node",
|
||||
command: "camera.clip",
|
||||
payload: {
|
||||
format: "mp4",
|
||||
base64: "aGk=",
|
||||
durationMs: 10_000,
|
||||
hasAudio: true,
|
||||
},
|
||||
});
|
||||
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(
|
||||
["nodes", "camera", "clip", "--node", "ios-node", "--duration", "10s"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(callGateway).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "node.invoke",
|
||||
params: expect.objectContaining({
|
||||
nodeId: "ios-node",
|
||||
command: "camera.clip",
|
||||
params: expect.objectContaining({ durationMs: 10_000 }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("runs nodes canvas snapshot and prints MEDIA path", async () => {
|
||||
callGateway
|
||||
.mockResolvedValueOnce({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS Node",
|
||||
remoteIp: "192.168.0.88",
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
nodeId: "ios-node",
|
||||
command: "canvas.snapshot",
|
||||
payload: { format: "png", base64: "aGk=" },
|
||||
});
|
||||
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(
|
||||
["nodes", "canvas", "snapshot", "--node", "ios-node", "--format", "png"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
const out = String(runtime.log.mock.calls[0]?.[0] ?? "");
|
||||
const mediaPath = out.replace(/^MEDIA:/, "").trim();
|
||||
expect(mediaPath).toMatch(/clawdbot-canvas-snapshot-.*\.png$/);
|
||||
|
||||
try {
|
||||
await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("hi");
|
||||
} finally {
|
||||
await fs.unlink(mediaPath).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("fails nodes camera snap on invalid facing", async () => {
|
||||
callGateway.mockResolvedValueOnce({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS Node",
|
||||
remoteIp: "192.168.0.88",
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const program = buildProgram();
|
||||
runtime.error.mockClear();
|
||||
|
||||
await expect(
|
||||
program.parseAsync(
|
||||
["nodes", "camera", "snap", "--node", "ios-node", "--facing", "nope"],
|
||||
{ from: "user" },
|
||||
),
|
||||
).rejects.toThrow(/exit/i);
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/invalid facing/i),
|
||||
);
|
||||
});
|
||||
});
|
||||
1350
src/cli/program.ts
1350
src/cli/program.ts
File diff suppressed because it is too large
Load Diff
35
src/cli/program/build-program.ts
Normal file
35
src/cli/program/build-program.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Command } from "commander";
|
||||
import { registerBrowserCli } from "../browser-cli.js";
|
||||
import { createProgramContext } from "./context.js";
|
||||
import { configureProgramHelp } from "./help.js";
|
||||
import { registerPreActionHooks } from "./preaction.js";
|
||||
import { registerAgentCommands } from "./register.agent.js";
|
||||
import { registerConfigureCommand } from "./register.configure.js";
|
||||
import { registerMaintenanceCommands } from "./register.maintenance.js";
|
||||
import { registerMessageCommands } from "./register.message.js";
|
||||
import { registerOnboardCommand } from "./register.onboard.js";
|
||||
import { registerSetupCommand } from "./register.setup.js";
|
||||
import { registerStatusHealthSessionsCommands } from "./register.status-health-sessions.js";
|
||||
import { registerSubCliCommands } from "./register.subclis.js";
|
||||
|
||||
export function buildProgram() {
|
||||
const program = new Command();
|
||||
const ctx = createProgramContext();
|
||||
|
||||
configureProgramHelp(program, ctx);
|
||||
registerPreActionHooks(program, ctx.programVersion);
|
||||
|
||||
registerSetupCommand(program);
|
||||
registerOnboardCommand(program);
|
||||
registerConfigureCommand(program);
|
||||
registerMaintenanceCommands(program);
|
||||
registerMessageCommands(program, ctx);
|
||||
registerAgentCommands(program, {
|
||||
agentChannelOptions: ctx.agentChannelOptions,
|
||||
});
|
||||
registerSubCliCommands(program);
|
||||
registerStatusHealthSessionsCommands(program);
|
||||
registerBrowserCli(program);
|
||||
|
||||
return program;
|
||||
}
|
||||
19
src/cli/program/context.ts
Normal file
19
src/cli/program/context.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import { VERSION } from "../../version.js";
|
||||
|
||||
export type ProgramContext = {
|
||||
programVersion: string;
|
||||
channelOptions: string[];
|
||||
messageChannelOptions: string;
|
||||
agentChannelOptions: string;
|
||||
};
|
||||
|
||||
export function createProgramContext(): ProgramContext {
|
||||
const channelOptions = listChannelPlugins().map((plugin) => plugin.id);
|
||||
return {
|
||||
programVersion: VERSION,
|
||||
channelOptions,
|
||||
messageChannelOptions: channelOptions.join("|"),
|
||||
agentChannelOptions: ["last", ...channelOptions].join("|"),
|
||||
};
|
||||
}
|
||||
95
src/cli/program/help.ts
Normal file
95
src/cli/program/help.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { Command } from "commander";
|
||||
import { formatDocsLink } from "../../terminal/links.js";
|
||||
import { isRich, theme } from "../../terminal/theme.js";
|
||||
import { formatCliBannerLine, hasEmittedCliBanner } from "../banner.js";
|
||||
import type { ProgramContext } from "./context.js";
|
||||
|
||||
const EXAMPLES = [
|
||||
[
|
||||
"clawdbot channels login --verbose",
|
||||
"Link personal WhatsApp Web and show QR + connection logs.",
|
||||
],
|
||||
[
|
||||
'clawdbot message send --to +15555550123 --message "Hi" --json',
|
||||
"Send via your web session and print JSON result.",
|
||||
],
|
||||
["clawdbot gateway --port 18789", "Run the WebSocket Gateway locally."],
|
||||
[
|
||||
"clawdbot --dev gateway",
|
||||
"Run a dev Gateway (isolated state/config) on ws://127.0.0.1:19001.",
|
||||
],
|
||||
[
|
||||
"clawdbot gateway --force",
|
||||
"Kill anything bound to the default gateway port, then start it.",
|
||||
],
|
||||
["clawdbot gateway ...", "Gateway control via WebSocket."],
|
||||
[
|
||||
'clawdbot agent --to +15555550123 --message "Run summary" --deliver',
|
||||
"Talk directly to the agent using the Gateway; optionally send the WhatsApp reply.",
|
||||
],
|
||||
[
|
||||
'clawdbot message send --channel telegram --to @mychat --message "Hi"',
|
||||
"Send via your Telegram bot.",
|
||||
],
|
||||
] as const;
|
||||
|
||||
export function configureProgramHelp(program: Command, ctx: ProgramContext) {
|
||||
program
|
||||
.name("clawdbot")
|
||||
.description("")
|
||||
.version(ctx.programVersion)
|
||||
.option(
|
||||
"--dev",
|
||||
"Dev profile: isolate state under ~/.clawdbot-dev, default gateway port 19001, and shift derived ports (bridge/browser/canvas)",
|
||||
)
|
||||
.option(
|
||||
"--profile <name>",
|
||||
"Use a named profile (isolates CLAWDBOT_STATE_DIR/CLAWDBOT_CONFIG_PATH under ~/.clawdbot-<name>)",
|
||||
);
|
||||
|
||||
program.option("--no-color", "Disable ANSI colors", false);
|
||||
|
||||
program.configureHelp({
|
||||
optionTerm: (option) => theme.option(option.flags),
|
||||
subcommandTerm: (cmd) => theme.command(cmd.name()),
|
||||
});
|
||||
|
||||
program.configureOutput({
|
||||
writeOut: (str) => {
|
||||
const colored = str
|
||||
.replace(/^Usage:/gm, theme.heading("Usage:"))
|
||||
.replace(/^Options:/gm, theme.heading("Options:"))
|
||||
.replace(/^Commands:/gm, theme.heading("Commands:"));
|
||||
process.stdout.write(colored);
|
||||
},
|
||||
writeErr: (str) => process.stderr.write(str),
|
||||
outputError: (str, write) => write(theme.error(str)),
|
||||
});
|
||||
|
||||
if (
|
||||
process.argv.includes("-V") ||
|
||||
process.argv.includes("--version") ||
|
||||
process.argv.includes("-v")
|
||||
) {
|
||||
console.log(ctx.programVersion);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
program.addHelpText("beforeAll", () => {
|
||||
if (hasEmittedCliBanner()) return "";
|
||||
const rich = isRich();
|
||||
const line = formatCliBannerLine(ctx.programVersion, { richTty: rich });
|
||||
return `\n${line}\n`;
|
||||
});
|
||||
|
||||
const fmtExamples = EXAMPLES.map(
|
||||
([cmd, desc]) => ` ${theme.command(cmd)}\n ${theme.muted(desc)}`,
|
||||
).join("\n");
|
||||
|
||||
program.addHelpText("afterAll", () => {
|
||||
const docs = formatDocsLink("/cli", "docs.clawd.bot/cli");
|
||||
return `\n${theme.heading("Examples:")}\n${fmtExamples}\n\n${theme.muted(
|
||||
"Docs:",
|
||||
)} ${docs}\n`;
|
||||
});
|
||||
}
|
||||
23
src/cli/program/helpers.ts
Normal file
23
src/cli/program/helpers.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export function collectOption(
|
||||
value: string,
|
||||
previous: string[] = [],
|
||||
): string[] {
|
||||
return [...previous, value];
|
||||
}
|
||||
|
||||
export function parsePositiveIntOrUndefined(
|
||||
value: unknown,
|
||||
): number | undefined {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) return undefined;
|
||||
const parsed = Math.trunc(value);
|
||||
return parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(parsed) || parsed <= 0) return undefined;
|
||||
return parsed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
77
src/cli/program/message/helpers.ts
Normal file
77
src/cli/program/message/helpers.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Command } from "commander";
|
||||
import { messageCommand } from "../../../commands/message.js";
|
||||
import { danger, setVerbose } from "../../../globals.js";
|
||||
import { defaultRuntime } from "../../../runtime.js";
|
||||
import { createDefaultDeps } from "../../deps.js";
|
||||
|
||||
export type MessageCliHelpers = {
|
||||
withMessageBase: (command: Command) => Command;
|
||||
withMessageTarget: (command: Command) => Command;
|
||||
withRequiredMessageTarget: (command: Command) => Command;
|
||||
runMessageAction: (
|
||||
action: string,
|
||||
opts: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export function createMessageCliHelpers(
|
||||
message: Command,
|
||||
messageChannelOptions: string,
|
||||
): MessageCliHelpers {
|
||||
const withMessageBase = (command: Command) =>
|
||||
command
|
||||
.option("--channel <channel>", `Channel: ${messageChannelOptions}`)
|
||||
.option("--account <id>", "Channel account id (accountId)")
|
||||
.option("--json", "Output result as JSON", false)
|
||||
.option("--dry-run", "Print payload and skip sending", false)
|
||||
.option("--verbose", "Verbose logging", false);
|
||||
|
||||
const withMessageTarget = (command: Command) =>
|
||||
command.option(
|
||||
"-t, --to <dest>",
|
||||
"Recipient/channel: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord/Slack channel/user, or iMessage handle/chat_id",
|
||||
);
|
||||
const withRequiredMessageTarget = (command: Command) =>
|
||||
command.requiredOption(
|
||||
"-t, --to <dest>",
|
||||
"Recipient/channel: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord/Slack channel/user, or iMessage handle/chat_id",
|
||||
);
|
||||
|
||||
const runMessageAction = async (
|
||||
action: string,
|
||||
opts: Record<string, unknown>,
|
||||
) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
const deps = createDefaultDeps();
|
||||
try {
|
||||
await messageCommand(
|
||||
{
|
||||
...(() => {
|
||||
const { account, ...rest } = opts;
|
||||
return {
|
||||
...rest,
|
||||
accountId: typeof account === "string" ? account : undefined,
|
||||
};
|
||||
})(),
|
||||
action,
|
||||
},
|
||||
deps,
|
||||
defaultRuntime,
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// `message` is only used for `message.help({ error: true })`, keep the
|
||||
// command-specific helpers grouped here.
|
||||
void message;
|
||||
|
||||
return {
|
||||
withMessageBase,
|
||||
withMessageTarget,
|
||||
withRequiredMessageTarget,
|
||||
runMessageAction,
|
||||
};
|
||||
}
|
||||
166
src/cli/program/message/register.discord-admin.ts
Normal file
166
src/cli/program/message/register.discord-admin.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { Command } from "commander";
|
||||
import type { MessageCliHelpers } from "./helpers.js";
|
||||
|
||||
export function registerMessageDiscordAdminCommands(
|
||||
message: Command,
|
||||
helpers: MessageCliHelpers,
|
||||
) {
|
||||
const role = message.command("role").description("Role actions");
|
||||
helpers
|
||||
.withMessageBase(
|
||||
role
|
||||
.command("info")
|
||||
.description("List roles")
|
||||
.requiredOption("--guild-id <id>", "Guild id"),
|
||||
)
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("role-info", opts);
|
||||
});
|
||||
|
||||
helpers
|
||||
.withMessageBase(
|
||||
role
|
||||
.command("add")
|
||||
.description("Add role to a member")
|
||||
.requiredOption("--guild-id <id>", "Guild id")
|
||||
.requiredOption("--user-id <id>", "User id")
|
||||
.requiredOption("--role-id <id>", "Role id"),
|
||||
)
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("role-add", opts);
|
||||
});
|
||||
|
||||
helpers
|
||||
.withMessageBase(
|
||||
role
|
||||
.command("remove")
|
||||
.description("Remove role from a member")
|
||||
.requiredOption("--guild-id <id>", "Guild id")
|
||||
.requiredOption("--user-id <id>", "User id")
|
||||
.requiredOption("--role-id <id>", "Role id"),
|
||||
)
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("role-remove", opts);
|
||||
});
|
||||
|
||||
const channel = message.command("channel").description("Channel actions");
|
||||
helpers
|
||||
.withMessageBase(
|
||||
channel
|
||||
.command("info")
|
||||
.description("Fetch channel info")
|
||||
.requiredOption("--channel-id <id>", "Channel id"),
|
||||
)
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("channel-info", opts);
|
||||
});
|
||||
|
||||
helpers
|
||||
.withMessageBase(
|
||||
channel
|
||||
.command("list")
|
||||
.description("List channels")
|
||||
.requiredOption("--guild-id <id>", "Guild id"),
|
||||
)
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("channel-list", opts);
|
||||
});
|
||||
|
||||
const member = message.command("member").description("Member actions");
|
||||
helpers
|
||||
.withMessageBase(
|
||||
member
|
||||
.command("info")
|
||||
.description("Fetch member info")
|
||||
.requiredOption("--user-id <id>", "User id"),
|
||||
)
|
||||
.option("--guild-id <id>", "Guild id (Discord)")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("member-info", opts);
|
||||
});
|
||||
|
||||
const voice = message.command("voice").description("Voice actions");
|
||||
helpers
|
||||
.withMessageBase(
|
||||
voice
|
||||
.command("status")
|
||||
.description("Fetch voice status")
|
||||
.requiredOption("--guild-id <id>", "Guild id")
|
||||
.requiredOption("--user-id <id>", "User id"),
|
||||
)
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("voice-status", opts);
|
||||
});
|
||||
|
||||
const event = message.command("event").description("Event actions");
|
||||
helpers
|
||||
.withMessageBase(
|
||||
event
|
||||
.command("list")
|
||||
.description("List scheduled events")
|
||||
.requiredOption("--guild-id <id>", "Guild id"),
|
||||
)
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("event-list", opts);
|
||||
});
|
||||
|
||||
helpers
|
||||
.withMessageBase(
|
||||
event
|
||||
.command("create")
|
||||
.description("Create a scheduled event")
|
||||
.requiredOption("--guild-id <id>", "Guild id")
|
||||
.requiredOption("--event-name <name>", "Event name")
|
||||
.requiredOption("--start-time <iso>", "Event start time"),
|
||||
)
|
||||
.option("--end-time <iso>", "Event end time")
|
||||
.option("--desc <text>", "Event description")
|
||||
.option("--channel-id <id>", "Channel id")
|
||||
.option("--location <text>", "Event location")
|
||||
.option("--event-type <stage|external|voice>", "Event type")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("event-create", opts);
|
||||
});
|
||||
|
||||
helpers
|
||||
.withMessageBase(
|
||||
message
|
||||
.command("timeout")
|
||||
.description("Timeout a member")
|
||||
.requiredOption("--guild-id <id>", "Guild id")
|
||||
.requiredOption("--user-id <id>", "User id"),
|
||||
)
|
||||
.option("--duration-min <n>", "Timeout duration minutes")
|
||||
.option("--until <iso>", "Timeout until")
|
||||
.option("--reason <text>", "Moderation reason")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("timeout", opts);
|
||||
});
|
||||
|
||||
helpers
|
||||
.withMessageBase(
|
||||
message
|
||||
.command("kick")
|
||||
.description("Kick a member")
|
||||
.requiredOption("--guild-id <id>", "Guild id")
|
||||
.requiredOption("--user-id <id>", "User id"),
|
||||
)
|
||||
.option("--reason <text>", "Moderation reason")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("kick", opts);
|
||||
});
|
||||
|
||||
helpers
|
||||
.withMessageBase(
|
||||
message
|
||||
.command("ban")
|
||||
.description("Ban a member")
|
||||
.requiredOption("--guild-id <id>", "Guild id")
|
||||
.requiredOption("--user-id <id>", "User id"),
|
||||
)
|
||||
.option("--reason <text>", "Moderation reason")
|
||||
.option("--delete-days <n>", "Ban delete message days")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("ban", opts);
|
||||
});
|
||||
}
|
||||
70
src/cli/program/message/register.emoji-sticker.ts
Normal file
70
src/cli/program/message/register.emoji-sticker.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { Command } from "commander";
|
||||
import { collectOption } from "../helpers.js";
|
||||
import type { MessageCliHelpers } from "./helpers.js";
|
||||
|
||||
export function registerMessageEmojiCommands(
|
||||
message: Command,
|
||||
helpers: MessageCliHelpers,
|
||||
) {
|
||||
const emoji = message.command("emoji").description("Emoji actions");
|
||||
|
||||
helpers
|
||||
.withMessageBase(emoji.command("list").description("List emojis"))
|
||||
.option("--guild-id <id>", "Guild id (Discord)")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("emoji-list", opts);
|
||||
});
|
||||
|
||||
helpers
|
||||
.withMessageBase(
|
||||
emoji
|
||||
.command("upload")
|
||||
.description("Upload an emoji")
|
||||
.requiredOption("--guild-id <id>", "Guild id"),
|
||||
)
|
||||
.requiredOption("--emoji-name <name>", "Emoji name")
|
||||
.requiredOption("--media <path-or-url>", "Emoji media (path or URL)")
|
||||
.option(
|
||||
"--role-ids <id>",
|
||||
"Role id (repeat)",
|
||||
collectOption,
|
||||
[] as string[],
|
||||
)
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("emoji-upload", opts);
|
||||
});
|
||||
}
|
||||
|
||||
export function registerMessageStickerCommands(
|
||||
message: Command,
|
||||
helpers: MessageCliHelpers,
|
||||
) {
|
||||
const sticker = message.command("sticker").description("Sticker actions");
|
||||
|
||||
helpers
|
||||
.withMessageBase(
|
||||
helpers.withRequiredMessageTarget(
|
||||
sticker.command("send").description("Send stickers"),
|
||||
),
|
||||
)
|
||||
.requiredOption("--sticker-id <id>", "Sticker id (repeat)", collectOption)
|
||||
.option("-m, --message <text>", "Optional message body")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("sticker", opts);
|
||||
});
|
||||
|
||||
helpers
|
||||
.withMessageBase(
|
||||
sticker
|
||||
.command("upload")
|
||||
.description("Upload a sticker")
|
||||
.requiredOption("--guild-id <id>", "Guild id"),
|
||||
)
|
||||
.requiredOption("--sticker-name <name>", "Sticker name")
|
||||
.requiredOption("--sticker-desc <text>", "Sticker description")
|
||||
.requiredOption("--sticker-tags <tags>", "Sticker tags")
|
||||
.requiredOption("--media <path-or-url>", "Sticker media (path or URL)")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("sticker-upload", opts);
|
||||
});
|
||||
}
|
||||
49
src/cli/program/message/register.permissions-search.ts
Normal file
49
src/cli/program/message/register.permissions-search.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Command } from "commander";
|
||||
import { collectOption } from "../helpers.js";
|
||||
import type { MessageCliHelpers } from "./helpers.js";
|
||||
|
||||
export function registerMessagePermissionsCommand(
|
||||
message: Command,
|
||||
helpers: MessageCliHelpers,
|
||||
) {
|
||||
helpers
|
||||
.withMessageBase(
|
||||
helpers.withMessageTarget(
|
||||
message.command("permissions").description("Fetch channel permissions"),
|
||||
),
|
||||
)
|
||||
.option("--channel-id <id>", "Channel id (defaults to --to)")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("permissions", opts);
|
||||
});
|
||||
}
|
||||
|
||||
export function registerMessageSearchCommand(
|
||||
message: Command,
|
||||
helpers: MessageCliHelpers,
|
||||
) {
|
||||
helpers
|
||||
.withMessageBase(
|
||||
message.command("search").description("Search Discord messages"),
|
||||
)
|
||||
.requiredOption("--guild-id <id>", "Guild id")
|
||||
.requiredOption("--query <text>", "Search query")
|
||||
.option("--channel-id <id>", "Channel id")
|
||||
.option(
|
||||
"--channel-ids <id>",
|
||||
"Channel id (repeat)",
|
||||
collectOption,
|
||||
[] as string[],
|
||||
)
|
||||
.option("--author-id <id>", "Author id")
|
||||
.option(
|
||||
"--author-ids <id>",
|
||||
"Author id (repeat)",
|
||||
collectOption,
|
||||
[] as string[],
|
||||
)
|
||||
.option("--limit <n>", "Result limit")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("search", opts);
|
||||
});
|
||||
}
|
||||
53
src/cli/program/message/register.pins.ts
Normal file
53
src/cli/program/message/register.pins.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { Command } from "commander";
|
||||
import type { MessageCliHelpers } from "./helpers.js";
|
||||
|
||||
export function registerMessagePinCommands(
|
||||
message: Command,
|
||||
helpers: MessageCliHelpers,
|
||||
) {
|
||||
const withPinsTarget = (command: Command) =>
|
||||
command.option(
|
||||
"--channel-id <id>",
|
||||
"Channel id (defaults to --to; required for WhatsApp)",
|
||||
);
|
||||
|
||||
const pins = [
|
||||
helpers
|
||||
.withMessageBase(
|
||||
withPinsTarget(
|
||||
helpers.withMessageTarget(
|
||||
message.command("pin").description("Pin a message"),
|
||||
),
|
||||
),
|
||||
)
|
||||
.requiredOption("--message-id <id>", "Message id")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("pin", opts);
|
||||
}),
|
||||
helpers
|
||||
.withMessageBase(
|
||||
withPinsTarget(
|
||||
helpers.withMessageTarget(
|
||||
message.command("unpin").description("Unpin a message"),
|
||||
),
|
||||
),
|
||||
)
|
||||
.requiredOption("--message-id <id>", "Message id")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("unpin", opts);
|
||||
}),
|
||||
helpers
|
||||
.withMessageBase(
|
||||
helpers.withMessageTarget(
|
||||
message.command("pins").description("List pinned messages"),
|
||||
),
|
||||
)
|
||||
.option("--channel-id <id>", "Channel id (defaults to --to)")
|
||||
.option("--limit <n>", "Result limit")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("list-pins", opts);
|
||||
}),
|
||||
] as const;
|
||||
|
||||
void pins;
|
||||
}
|
||||
28
src/cli/program/message/register.poll.ts
Normal file
28
src/cli/program/message/register.poll.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Command } from "commander";
|
||||
import { collectOption } from "../helpers.js";
|
||||
import type { MessageCliHelpers } from "./helpers.js";
|
||||
|
||||
export function registerMessagePollCommand(
|
||||
message: Command,
|
||||
helpers: MessageCliHelpers,
|
||||
) {
|
||||
helpers
|
||||
.withMessageBase(
|
||||
helpers.withRequiredMessageTarget(
|
||||
message.command("poll").description("Send a poll"),
|
||||
),
|
||||
)
|
||||
.requiredOption("--poll-question <text>", "Poll question")
|
||||
.option(
|
||||
"--poll-option <choice>",
|
||||
"Poll option (repeat 2-12 times)",
|
||||
collectOption,
|
||||
[] as string[],
|
||||
)
|
||||
.option("--poll-multi", "Allow multiple selections", false)
|
||||
.option("--poll-duration-hours <n>", "Poll duration (Discord)")
|
||||
.option("-m, --message <text>", "Optional message body")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("poll", opts);
|
||||
});
|
||||
}
|
||||
36
src/cli/program/message/register.reactions.ts
Normal file
36
src/cli/program/message/register.reactions.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Command } from "commander";
|
||||
import type { MessageCliHelpers } from "./helpers.js";
|
||||
|
||||
export function registerMessageReactionsCommands(
|
||||
message: Command,
|
||||
helpers: MessageCliHelpers,
|
||||
) {
|
||||
helpers
|
||||
.withMessageBase(
|
||||
helpers.withMessageTarget(
|
||||
message.command("react").description("Add or remove a reaction"),
|
||||
),
|
||||
)
|
||||
.requiredOption("--message-id <id>", "Message id")
|
||||
.option("--emoji <emoji>", "Emoji for reactions")
|
||||
.option("--remove", "Remove reaction", false)
|
||||
.option("--participant <id>", "WhatsApp reaction participant")
|
||||
.option("--from-me", "WhatsApp reaction fromMe", false)
|
||||
.option("--channel-id <id>", "Channel id (defaults to --to)")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("react", opts);
|
||||
});
|
||||
|
||||
helpers
|
||||
.withMessageBase(
|
||||
helpers.withMessageTarget(
|
||||
message.command("reactions").description("List reactions on a message"),
|
||||
),
|
||||
)
|
||||
.requiredOption("--message-id <id>", "Message id")
|
||||
.option("--limit <n>", "Result limit")
|
||||
.option("--channel-id <id>", "Channel id (defaults to --to)")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("reactions", opts);
|
||||
});
|
||||
}
|
||||
53
src/cli/program/message/register.read-edit-delete.ts
Normal file
53
src/cli/program/message/register.read-edit-delete.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { Command } from "commander";
|
||||
import type { MessageCliHelpers } from "./helpers.js";
|
||||
|
||||
export function registerMessageReadEditDeleteCommands(
|
||||
message: Command,
|
||||
helpers: MessageCliHelpers,
|
||||
) {
|
||||
helpers
|
||||
.withMessageBase(
|
||||
helpers.withMessageTarget(
|
||||
message.command("read").description("Read recent messages"),
|
||||
),
|
||||
)
|
||||
.option("--limit <n>", "Result limit")
|
||||
.option("--before <id>", "Read/search before id")
|
||||
.option("--after <id>", "Read/search after id")
|
||||
.option("--around <id>", "Read around id")
|
||||
.option("--channel-id <id>", "Channel id (defaults to --to)")
|
||||
.option("--include-thread", "Include thread replies (Discord)", false)
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("read", opts);
|
||||
});
|
||||
|
||||
helpers
|
||||
.withMessageBase(
|
||||
helpers.withMessageTarget(
|
||||
message
|
||||
.command("edit")
|
||||
.description("Edit a message")
|
||||
.requiredOption("--message-id <id>", "Message id")
|
||||
.requiredOption("-m, --message <text>", "Message body"),
|
||||
),
|
||||
)
|
||||
.option("--channel-id <id>", "Channel id (defaults to --to)")
|
||||
.option("--thread-id <id>", "Thread id (Telegram forum thread)")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("edit", opts);
|
||||
});
|
||||
|
||||
helpers
|
||||
.withMessageBase(
|
||||
helpers.withMessageTarget(
|
||||
message
|
||||
.command("delete")
|
||||
.description("Delete a message")
|
||||
.requiredOption("--message-id <id>", "Message id"),
|
||||
),
|
||||
)
|
||||
.option("--channel-id <id>", "Channel id (defaults to --to)")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("delete", opts);
|
||||
});
|
||||
}
|
||||
36
src/cli/program/message/register.send.ts
Normal file
36
src/cli/program/message/register.send.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Command } from "commander";
|
||||
import type { MessageCliHelpers } from "./helpers.js";
|
||||
|
||||
export function registerMessageSendCommand(
|
||||
message: Command,
|
||||
helpers: MessageCliHelpers,
|
||||
) {
|
||||
helpers
|
||||
.withMessageBase(
|
||||
helpers
|
||||
.withRequiredMessageTarget(
|
||||
message
|
||||
.command("send")
|
||||
.description("Send a message")
|
||||
.requiredOption("-m, --message <text>", "Message body"),
|
||||
)
|
||||
.option(
|
||||
"--media <path-or-url>",
|
||||
"Attach media (image/audio/video/document). Accepts local paths or URLs.",
|
||||
)
|
||||
.option(
|
||||
"--buttons <json>",
|
||||
"Telegram inline keyboard buttons as JSON (array of button rows)",
|
||||
)
|
||||
.option("--reply-to <id>", "Reply-to message id")
|
||||
.option("--thread-id <id>", "Thread id (Telegram forum thread)")
|
||||
.option(
|
||||
"--gif-playback",
|
||||
"Treat video media as GIF playback (WhatsApp only).",
|
||||
false,
|
||||
),
|
||||
)
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("send", opts);
|
||||
});
|
||||
}
|
||||
58
src/cli/program/message/register.thread.ts
Normal file
58
src/cli/program/message/register.thread.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { Command } from "commander";
|
||||
import type { MessageCliHelpers } from "./helpers.js";
|
||||
|
||||
export function registerMessageThreadCommands(
|
||||
message: Command,
|
||||
helpers: MessageCliHelpers,
|
||||
) {
|
||||
const thread = message.command("thread").description("Thread actions");
|
||||
|
||||
helpers
|
||||
.withMessageBase(
|
||||
helpers.withMessageTarget(
|
||||
thread
|
||||
.command("create")
|
||||
.description("Create a thread")
|
||||
.requiredOption("--thread-name <name>", "Thread name"),
|
||||
),
|
||||
)
|
||||
.option("--channel-id <id>", "Channel id (defaults to --to)")
|
||||
.option("--message-id <id>", "Message id (optional)")
|
||||
.option("--auto-archive-min <n>", "Thread auto-archive minutes")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("thread-create", opts);
|
||||
});
|
||||
|
||||
helpers
|
||||
.withMessageBase(
|
||||
thread
|
||||
.command("list")
|
||||
.description("List threads")
|
||||
.requiredOption("--guild-id <id>", "Guild id"),
|
||||
)
|
||||
.option("--channel-id <id>", "Channel id")
|
||||
.option("--include-archived", "Include archived threads", false)
|
||||
.option("--before <id>", "Read/search before id")
|
||||
.option("--limit <n>", "Result limit")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("thread-list", opts);
|
||||
});
|
||||
|
||||
helpers
|
||||
.withMessageBase(
|
||||
helpers.withRequiredMessageTarget(
|
||||
thread
|
||||
.command("reply")
|
||||
.description("Reply in a thread")
|
||||
.requiredOption("-m, --message <text>", "Message body"),
|
||||
),
|
||||
)
|
||||
.option(
|
||||
"--media <path-or-url>",
|
||||
"Attach media (image/audio/video/document). Accepts local paths or URLs.",
|
||||
)
|
||||
.option("--reply-to <id>", "Reply-to message id")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("thread-reply", opts);
|
||||
});
|
||||
}
|
||||
59
src/cli/program/preaction.ts
Normal file
59
src/cli/program/preaction.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
isNixMode,
|
||||
loadConfig,
|
||||
migrateLegacyConfig,
|
||||
readConfigFileSnapshot,
|
||||
writeConfigFile,
|
||||
} from "../../config/config.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { autoMigrateLegacyState } from "../../infra/state-migrations.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { emitCliBanner } from "../banner.js";
|
||||
|
||||
export function registerPreActionHooks(
|
||||
program: Command,
|
||||
programVersion: string,
|
||||
) {
|
||||
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
||||
emitCliBanner(programVersion);
|
||||
if (actionCommand.name() === "doctor") return;
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
if (snapshot.legacyIssues.length === 0) return;
|
||||
if (isNixMode) {
|
||||
defaultRuntime.error(
|
||||
danger(
|
||||
"Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and retry.",
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const migrated = migrateLegacyConfig(snapshot.parsed);
|
||||
if (migrated.config) {
|
||||
await writeConfigFile(migrated.config);
|
||||
if (migrated.changes.length > 0) {
|
||||
defaultRuntime.log(
|
||||
`Migrated legacy config entries:\n${migrated.changes
|
||||
.map((entry) => `- ${entry}`)
|
||||
.join("\n")}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const issues = snapshot.legacyIssues
|
||||
.map((issue) => `- ${issue.path}: ${issue.message}`)
|
||||
.join("\n");
|
||||
defaultRuntime.error(
|
||||
danger(
|
||||
`Legacy config entries detected. Run "clawdbot doctor" (or ask your agent) to migrate.\n${issues}`,
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
||||
if (actionCommand.name() === "doctor") return;
|
||||
const cfg = loadConfig();
|
||||
await autoMigrateLegacyState({ cfg });
|
||||
});
|
||||
}
|
||||
177
src/cli/program/register.agent.ts
Normal file
177
src/cli/program/register.agent.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import type { Command } from "commander";
|
||||
import { DEFAULT_CHAT_CHANNEL } from "../../channels/registry.js";
|
||||
import { agentCliCommand } from "../../commands/agent-via-gateway.js";
|
||||
import {
|
||||
agentsAddCommand,
|
||||
agentsDeleteCommand,
|
||||
agentsListCommand,
|
||||
} from "../../commands/agents.js";
|
||||
import { setVerbose } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { formatDocsLink } from "../../terminal/links.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import { hasExplicitOptions } from "../command-options.js";
|
||||
import { createDefaultDeps } from "../deps.js";
|
||||
import { collectOption } from "./helpers.js";
|
||||
|
||||
export function registerAgentCommands(
|
||||
program: Command,
|
||||
args: { agentChannelOptions: string },
|
||||
) {
|
||||
program
|
||||
.command("agent")
|
||||
.description("Run an agent turn via the Gateway (use --local for embedded)")
|
||||
.requiredOption("-m, --message <text>", "Message body for the agent")
|
||||
.option(
|
||||
"-t, --to <number>",
|
||||
"Recipient number in E.164 used to derive the session key",
|
||||
)
|
||||
.option("--session-id <id>", "Use an explicit session id")
|
||||
.option(
|
||||
"--thinking <level>",
|
||||
"Thinking level: off | minimal | low | medium | high",
|
||||
)
|
||||
.option("--verbose <on|off>", "Persist agent verbose level for the session")
|
||||
.option(
|
||||
"--channel <channel>",
|
||||
`Delivery channel: ${args.agentChannelOptions} (default: ${DEFAULT_CHAT_CHANNEL})`,
|
||||
)
|
||||
.option(
|
||||
"--local",
|
||||
"Run the embedded agent locally (requires model provider API keys in your shell)",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"--deliver",
|
||||
"Send the agent's reply back to the selected channel (requires --to)",
|
||||
false,
|
||||
)
|
||||
.option("--json", "Output result as JSON", false)
|
||||
.option(
|
||||
"--timeout <seconds>",
|
||||
"Override agent command timeout (seconds, default 600 or config value)",
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`
|
||||
Examples:
|
||||
clawdbot agent --to +15555550123 --message "status update"
|
||||
clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
|
||||
clawdbot agent --to +15555550123 --message "Trace logs" --verbose on --json
|
||||
clawdbot agent --to +15555550123 --message "Summon reply" --deliver
|
||||
|
||||
${theme.muted("Docs:")} ${formatDocsLink(
|
||||
"/agent-send",
|
||||
"docs.clawd.bot/agent-send",
|
||||
)}`,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
const verboseLevel =
|
||||
typeof opts.verbose === "string" ? opts.verbose.toLowerCase() : "";
|
||||
setVerbose(verboseLevel === "on");
|
||||
// Build default deps (keeps parity with other commands; future-proofing).
|
||||
const deps = createDefaultDeps();
|
||||
try {
|
||||
await agentCliCommand(opts, defaultRuntime, deps);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const agents = program
|
||||
.command("agents")
|
||||
.description("Manage isolated agents (workspaces + auth + routing)");
|
||||
|
||||
agents
|
||||
.command("list")
|
||||
.description("List configured agents")
|
||||
.option("--json", "Output JSON instead of text", false)
|
||||
.option("--bindings", "Include routing bindings", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
await agentsListCommand(
|
||||
{ json: Boolean(opts.json), bindings: Boolean(opts.bindings) },
|
||||
defaultRuntime,
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
agents
|
||||
.command("add [name]")
|
||||
.description("Add a new isolated agent")
|
||||
.option("--workspace <dir>", "Workspace directory for the new agent")
|
||||
.option("--model <id>", "Model id for this agent")
|
||||
.option("--agent-dir <dir>", "Agent state directory for this agent")
|
||||
.option(
|
||||
"--bind <channel[:accountId]>",
|
||||
"Route channel binding (repeatable)",
|
||||
collectOption,
|
||||
[],
|
||||
)
|
||||
.option("--non-interactive", "Disable prompts; requires --workspace", false)
|
||||
.option("--json", "Output JSON summary", false)
|
||||
.action(async (name, opts, command) => {
|
||||
try {
|
||||
const hasFlags = hasExplicitOptions(command, [
|
||||
"workspace",
|
||||
"model",
|
||||
"agentDir",
|
||||
"bind",
|
||||
"nonInteractive",
|
||||
]);
|
||||
await agentsAddCommand(
|
||||
{
|
||||
name: typeof name === "string" ? name : undefined,
|
||||
workspace: opts.workspace as string | undefined,
|
||||
model: opts.model as string | undefined,
|
||||
agentDir: opts.agentDir as string | undefined,
|
||||
bind: Array.isArray(opts.bind)
|
||||
? (opts.bind as string[])
|
||||
: undefined,
|
||||
nonInteractive: Boolean(opts.nonInteractive),
|
||||
json: Boolean(opts.json),
|
||||
},
|
||||
defaultRuntime,
|
||||
{ hasFlags },
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
agents
|
||||
.command("delete <id>")
|
||||
.description("Delete an agent and prune workspace/state")
|
||||
.option("--force", "Skip confirmation", false)
|
||||
.option("--json", "Output JSON summary", false)
|
||||
.action(async (id, opts) => {
|
||||
try {
|
||||
await agentsDeleteCommand(
|
||||
{
|
||||
id: String(id),
|
||||
force: Boolean(opts.force),
|
||||
json: Boolean(opts.json),
|
||||
},
|
||||
defaultRuntime,
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
agents.action(async () => {
|
||||
try {
|
||||
await agentsListCommand({}, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
59
src/cli/program/register.configure.ts
Normal file
59
src/cli/program/register.configure.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
CONFIGURE_WIZARD_SECTIONS,
|
||||
configureCommand,
|
||||
configureCommandWithSections,
|
||||
} from "../../commands/configure.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
|
||||
export function registerConfigureCommand(program: Command) {
|
||||
const register = (name: "configure" | "config") => {
|
||||
program
|
||||
.command(name)
|
||||
.description(
|
||||
name === "config"
|
||||
? "Alias for `clawdbot configure`"
|
||||
: "Interactive prompt to set up credentials, devices, and agent defaults",
|
||||
)
|
||||
.option(
|
||||
"--section <section>",
|
||||
`Configuration sections (repeatable). Options: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}`,
|
||||
(value: string, previous: string[]) => [...previous, value],
|
||||
[] as string[],
|
||||
)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const sections: string[] = Array.isArray(opts.section)
|
||||
? opts.section
|
||||
.map((value: unknown) =>
|
||||
typeof value === "string" ? value.trim() : "",
|
||||
)
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
if (sections.length === 0) {
|
||||
await configureCommand(defaultRuntime);
|
||||
return;
|
||||
}
|
||||
|
||||
const invalid = sections.filter(
|
||||
(s) => !CONFIGURE_WIZARD_SECTIONS.includes(s as never),
|
||||
);
|
||||
if (invalid.length > 0) {
|
||||
defaultRuntime.error(
|
||||
`Invalid --section: ${invalid.join(", ")}. Expected one of: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}.`,
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
await configureCommandWithSections(sections as never, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
register("configure");
|
||||
register("config");
|
||||
}
|
||||
123
src/cli/program/register.maintenance.ts
Normal file
123
src/cli/program/register.maintenance.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { Command } from "commander";
|
||||
import { dashboardCommand } from "../../commands/dashboard.js";
|
||||
import { doctorCommand } from "../../commands/doctor.js";
|
||||
import { resetCommand } from "../../commands/reset.js";
|
||||
import { uninstallCommand } from "../../commands/uninstall.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
|
||||
export function registerMaintenanceCommands(program: Command) {
|
||||
program
|
||||
.command("doctor")
|
||||
.description("Health checks + quick fixes for the gateway and channels")
|
||||
.option(
|
||||
"--no-workspace-suggestions",
|
||||
"Disable workspace memory system suggestions",
|
||||
false,
|
||||
)
|
||||
.option("--yes", "Accept defaults without prompting", false)
|
||||
.option("--repair", "Apply recommended repairs without prompting", false)
|
||||
.option(
|
||||
"--force",
|
||||
"Apply aggressive repairs (overwrites custom service config)",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"--non-interactive",
|
||||
"Run without prompts (safe migrations only)",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"--generate-gateway-token",
|
||||
"Generate and configure a gateway token",
|
||||
false,
|
||||
)
|
||||
.option("--deep", "Scan system services for extra gateway installs", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
await doctorCommand(defaultRuntime, {
|
||||
workspaceSuggestions: opts.workspaceSuggestions,
|
||||
yes: Boolean(opts.yes),
|
||||
repair: Boolean(opts.repair),
|
||||
force: Boolean(opts.force),
|
||||
nonInteractive: Boolean(opts.nonInteractive),
|
||||
generateGatewayToken: Boolean(opts.generateGatewayToken),
|
||||
deep: Boolean(opts.deep),
|
||||
});
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("dashboard")
|
||||
.description("Open the Control UI with your current token")
|
||||
.option("--no-open", "Print URL but do not launch a browser", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
await dashboardCommand(defaultRuntime, {
|
||||
noOpen: Boolean(opts.noOpen),
|
||||
});
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("reset")
|
||||
.description("Reset local config/state (keeps the CLI installed)")
|
||||
.option(
|
||||
"--scope <scope>",
|
||||
"config|config+creds+sessions|full (default: interactive prompt)",
|
||||
)
|
||||
.option("--yes", "Skip confirmation prompts", false)
|
||||
.option(
|
||||
"--non-interactive",
|
||||
"Disable prompts (requires --scope + --yes)",
|
||||
false,
|
||||
)
|
||||
.option("--dry-run", "Print actions without removing files", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
await resetCommand(defaultRuntime, {
|
||||
scope: opts.scope,
|
||||
yes: Boolean(opts.yes),
|
||||
nonInteractive: Boolean(opts.nonInteractive),
|
||||
dryRun: Boolean(opts.dryRun),
|
||||
});
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("uninstall")
|
||||
.description("Uninstall the gateway service + local data (CLI remains)")
|
||||
.option("--service", "Remove the gateway service", false)
|
||||
.option("--state", "Remove state + config", false)
|
||||
.option("--workspace", "Remove workspace dirs", false)
|
||||
.option("--app", "Remove the macOS app", false)
|
||||
.option("--all", "Remove service + state + workspace + app", false)
|
||||
.option("--yes", "Skip confirmation prompts", false)
|
||||
.option("--non-interactive", "Disable prompts (requires --yes)", false)
|
||||
.option("--dry-run", "Print actions without removing files", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
await uninstallCommand(defaultRuntime, {
|
||||
service: Boolean(opts.service),
|
||||
state: Boolean(opts.state),
|
||||
workspace: Boolean(opts.workspace),
|
||||
app: Boolean(opts.app),
|
||||
all: Boolean(opts.all),
|
||||
yes: Boolean(opts.yes),
|
||||
nonInteractive: Boolean(opts.nonInteractive),
|
||||
dryRun: Boolean(opts.dryRun),
|
||||
});
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
57
src/cli/program/register.message.ts
Normal file
57
src/cli/program/register.message.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Command } from "commander";
|
||||
import { formatDocsLink } from "../../terminal/links.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import type { ProgramContext } from "./context.js";
|
||||
import { createMessageCliHelpers } from "./message/helpers.js";
|
||||
import { registerMessageDiscordAdminCommands } from "./message/register.discord-admin.js";
|
||||
import {
|
||||
registerMessageEmojiCommands,
|
||||
registerMessageStickerCommands,
|
||||
} from "./message/register.emoji-sticker.js";
|
||||
import {
|
||||
registerMessagePermissionsCommand,
|
||||
registerMessageSearchCommand,
|
||||
} from "./message/register.permissions-search.js";
|
||||
import { registerMessagePinCommands } from "./message/register.pins.js";
|
||||
import { registerMessagePollCommand } from "./message/register.poll.js";
|
||||
import { registerMessageReactionsCommands } from "./message/register.reactions.js";
|
||||
import { registerMessageReadEditDeleteCommands } from "./message/register.read-edit-delete.js";
|
||||
import { registerMessageSendCommand } from "./message/register.send.js";
|
||||
import { registerMessageThreadCommands } from "./message/register.thread.js";
|
||||
|
||||
export function registerMessageCommands(program: Command, ctx: ProgramContext) {
|
||||
const message = program
|
||||
.command("message")
|
||||
.description("Send messages and channel actions")
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`
|
||||
Examples:
|
||||
clawdbot message send --to +15555550123 --message "Hi"
|
||||
clawdbot message send --to +15555550123 --message "Hi" --media photo.jpg
|
||||
clawdbot message poll --channel discord --to channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi
|
||||
clawdbot message react --channel discord --to 123 --message-id 456 --emoji "✅"
|
||||
|
||||
${theme.muted("Docs:")} ${formatDocsLink(
|
||||
"/cli/message",
|
||||
"docs.clawd.bot/cli/message",
|
||||
)}`,
|
||||
)
|
||||
.action(() => {
|
||||
message.help({ error: true });
|
||||
});
|
||||
|
||||
const helpers = createMessageCliHelpers(message, ctx.messageChannelOptions);
|
||||
registerMessageSendCommand(message, helpers);
|
||||
registerMessagePollCommand(message, helpers);
|
||||
registerMessageReactionsCommands(message, helpers);
|
||||
registerMessageReadEditDeleteCommands(message, helpers);
|
||||
registerMessagePinCommands(message, helpers);
|
||||
registerMessagePermissionsCommand(message, helpers);
|
||||
registerMessageSearchCommand(message, helpers);
|
||||
registerMessageThreadCommands(message, helpers);
|
||||
registerMessageEmojiCommands(message, helpers);
|
||||
registerMessageStickerCommands(message, helpers);
|
||||
registerMessageDiscordAdminCommands(message, helpers);
|
||||
}
|
||||
155
src/cli/program/register.onboard.ts
Normal file
155
src/cli/program/register.onboard.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { Command } from "commander";
|
||||
import type { GatewayDaemonRuntime } from "../../commands/daemon-runtime.js";
|
||||
import { onboardCommand } from "../../commands/onboard.js";
|
||||
import type {
|
||||
AuthChoice,
|
||||
GatewayAuthChoice,
|
||||
GatewayBind,
|
||||
NodeManagerChoice,
|
||||
TailscaleMode,
|
||||
} from "../../commands/onboard-types.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
|
||||
function resolveInstallDaemonFlag(
|
||||
command: unknown,
|
||||
opts: { installDaemon?: boolean },
|
||||
): boolean | undefined {
|
||||
if (!command || typeof command !== "object") return undefined;
|
||||
const getOptionValueSource =
|
||||
"getOptionValueSource" in command
|
||||
? command.getOptionValueSource
|
||||
: undefined;
|
||||
if (typeof getOptionValueSource !== "function") return undefined;
|
||||
|
||||
// Commander doesn't support option conflicts natively; keep original behavior.
|
||||
// If --skip-daemon is explicitly passed, it wins.
|
||||
if (getOptionValueSource.call(command, "skipDaemon") === "cli") return false;
|
||||
if (getOptionValueSource.call(command, "installDaemon") === "cli") {
|
||||
return Boolean(opts.installDaemon);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function registerOnboardCommand(program: Command) {
|
||||
program
|
||||
.command("onboard")
|
||||
.description(
|
||||
"Interactive wizard to set up the gateway, workspace, and skills",
|
||||
)
|
||||
.option("--workspace <dir>", "Agent workspace directory (default: ~/clawd)")
|
||||
.option(
|
||||
"--reset",
|
||||
"Reset config + credentials + sessions + workspace before running wizard",
|
||||
)
|
||||
.option("--non-interactive", "Run without prompts", false)
|
||||
.option("--flow <flow>", "Wizard flow: quickstart|advanced")
|
||||
.option("--mode <mode>", "Wizard mode: local|remote")
|
||||
.option(
|
||||
"--auth-choice <choice>",
|
||||
"Auth: setup-token|claude-cli|token|chutes|openai-codex|openai-api-key|openrouter-api-key|moonshot-api-key|synthetic-api-key|codex-cli|antigravity|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
|
||||
)
|
||||
.option(
|
||||
"--token-provider <id>",
|
||||
"Token provider id (non-interactive; used with --auth-choice token)",
|
||||
)
|
||||
.option(
|
||||
"--token <token>",
|
||||
"Token value (non-interactive; used with --auth-choice token)",
|
||||
)
|
||||
.option(
|
||||
"--token-profile-id <id>",
|
||||
"Auth profile id (non-interactive; default: <provider>:manual)",
|
||||
)
|
||||
.option(
|
||||
"--token-expires-in <duration>",
|
||||
"Optional token expiry duration (e.g. 365d, 12h)",
|
||||
)
|
||||
.option("--anthropic-api-key <key>", "Anthropic API key")
|
||||
.option("--openai-api-key <key>", "OpenAI API key")
|
||||
.option("--openrouter-api-key <key>", "OpenRouter API key")
|
||||
.option("--moonshot-api-key <key>", "Moonshot API key")
|
||||
.option("--gemini-api-key <key>", "Gemini API key")
|
||||
.option("--zai-api-key <key>", "Z.AI API key")
|
||||
.option("--minimax-api-key <key>", "MiniMax API key")
|
||||
.option("--synthetic-api-key <key>", "Synthetic API key")
|
||||
.option("--opencode-zen-api-key <key>", "OpenCode Zen API key")
|
||||
.option("--gateway-port <port>", "Gateway port")
|
||||
.option("--gateway-bind <mode>", "Gateway bind: loopback|lan|auto|custom")
|
||||
.option("--gateway-auth <mode>", "Gateway auth: off|token|password")
|
||||
.option("--gateway-token <token>", "Gateway token (token auth)")
|
||||
.option("--gateway-password <password>", "Gateway password (password auth)")
|
||||
.option("--remote-url <url>", "Remote Gateway WebSocket URL")
|
||||
.option("--remote-token <token>", "Remote Gateway token (optional)")
|
||||
.option("--tailscale <mode>", "Tailscale: off|serve|funnel")
|
||||
.option("--tailscale-reset-on-exit", "Reset tailscale serve/funnel on exit")
|
||||
.option("--install-daemon", "Install gateway daemon")
|
||||
.option("--no-install-daemon", "Skip gateway daemon install")
|
||||
.option("--skip-daemon", "Skip gateway daemon install")
|
||||
.option("--daemon-runtime <runtime>", "Daemon runtime: node|bun")
|
||||
.option("--skip-channels", "Skip channel setup")
|
||||
.option("--skip-skills", "Skip skills setup")
|
||||
.option("--skip-health", "Skip health check")
|
||||
.option("--skip-ui", "Skip Control UI/TUI prompts")
|
||||
.option("--node-manager <name>", "Node manager for skills: npm|pnpm|bun")
|
||||
.option("--json", "Output JSON summary", false)
|
||||
.action(async (opts, command) => {
|
||||
try {
|
||||
const installDaemon = resolveInstallDaemonFlag(command, {
|
||||
installDaemon: Boolean(opts.installDaemon),
|
||||
});
|
||||
const gatewayPort =
|
||||
typeof opts.gatewayPort === "string"
|
||||
? Number.parseInt(opts.gatewayPort, 10)
|
||||
: undefined;
|
||||
await onboardCommand(
|
||||
{
|
||||
workspace: opts.workspace as string | undefined,
|
||||
nonInteractive: Boolean(opts.nonInteractive),
|
||||
flow: opts.flow as "quickstart" | "advanced" | undefined,
|
||||
mode: opts.mode as "local" | "remote" | undefined,
|
||||
authChoice: opts.authChoice as AuthChoice | undefined,
|
||||
tokenProvider: opts.tokenProvider as string | undefined,
|
||||
token: opts.token as string | undefined,
|
||||
tokenProfileId: opts.tokenProfileId as string | undefined,
|
||||
tokenExpiresIn: opts.tokenExpiresIn as string | undefined,
|
||||
anthropicApiKey: opts.anthropicApiKey as string | undefined,
|
||||
openaiApiKey: opts.openaiApiKey as string | undefined,
|
||||
openrouterApiKey: opts.openrouterApiKey as string | undefined,
|
||||
moonshotApiKey: opts.moonshotApiKey as string | undefined,
|
||||
geminiApiKey: opts.geminiApiKey as string | undefined,
|
||||
zaiApiKey: opts.zaiApiKey as string | undefined,
|
||||
minimaxApiKey: opts.minimaxApiKey as string | undefined,
|
||||
syntheticApiKey: opts.syntheticApiKey as string | undefined,
|
||||
opencodeZenApiKey: opts.opencodeZenApiKey as string | undefined,
|
||||
gatewayPort:
|
||||
typeof gatewayPort === "number" && Number.isFinite(gatewayPort)
|
||||
? gatewayPort
|
||||
: undefined,
|
||||
gatewayBind: opts.gatewayBind as GatewayBind | undefined,
|
||||
gatewayAuth: opts.gatewayAuth as GatewayAuthChoice | undefined,
|
||||
gatewayToken: opts.gatewayToken as string | undefined,
|
||||
gatewayPassword: opts.gatewayPassword as string | undefined,
|
||||
remoteUrl: opts.remoteUrl as string | undefined,
|
||||
remoteToken: opts.remoteToken as string | undefined,
|
||||
tailscale: opts.tailscale as TailscaleMode | undefined,
|
||||
tailscaleResetOnExit: Boolean(opts.tailscaleResetOnExit),
|
||||
reset: Boolean(opts.reset),
|
||||
installDaemon,
|
||||
daemonRuntime: opts.daemonRuntime as
|
||||
| GatewayDaemonRuntime
|
||||
| undefined,
|
||||
skipChannels: Boolean(opts.skipChannels),
|
||||
skipSkills: Boolean(opts.skipSkills),
|
||||
skipHealth: Boolean(opts.skipHealth),
|
||||
skipUi: Boolean(opts.skipUi),
|
||||
nodeManager: opts.nodeManager as NodeManagerChoice | undefined,
|
||||
json: Boolean(opts.json),
|
||||
},
|
||||
defaultRuntime,
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
51
src/cli/program/register.setup.ts
Normal file
51
src/cli/program/register.setup.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Command } from "commander";
|
||||
import { onboardCommand } from "../../commands/onboard.js";
|
||||
import { setupCommand } from "../../commands/setup.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { hasExplicitOptions } from "../command-options.js";
|
||||
|
||||
export function registerSetupCommand(program: Command) {
|
||||
program
|
||||
.command("setup")
|
||||
.description("Initialize ~/.clawdbot/clawdbot.json and the agent workspace")
|
||||
.option(
|
||||
"--workspace <dir>",
|
||||
"Agent workspace directory (default: ~/clawd; stored as agents.defaults.workspace)",
|
||||
)
|
||||
.option("--wizard", "Run the interactive onboarding wizard", false)
|
||||
.option("--non-interactive", "Run the wizard without prompts", false)
|
||||
.option("--mode <mode>", "Wizard mode: local|remote")
|
||||
.option("--remote-url <url>", "Remote Gateway WebSocket URL")
|
||||
.option("--remote-token <token>", "Remote Gateway token (optional)")
|
||||
.action(async (opts, command) => {
|
||||
try {
|
||||
const hasWizardFlags = hasExplicitOptions(command, [
|
||||
"wizard",
|
||||
"nonInteractive",
|
||||
"mode",
|
||||
"remoteUrl",
|
||||
"remoteToken",
|
||||
]);
|
||||
if (opts.wizard || hasWizardFlags) {
|
||||
await onboardCommand(
|
||||
{
|
||||
workspace: opts.workspace as string | undefined,
|
||||
nonInteractive: Boolean(opts.nonInteractive),
|
||||
mode: opts.mode as "local" | "remote" | undefined,
|
||||
remoteUrl: opts.remoteUrl as string | undefined,
|
||||
remoteToken: opts.remoteToken as string | undefined,
|
||||
},
|
||||
defaultRuntime,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await setupCommand(
|
||||
{ workspace: opts.workspace as string | undefined },
|
||||
defaultRuntime,
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
133
src/cli/program/register.status-health-sessions.ts
Normal file
133
src/cli/program/register.status-health-sessions.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { Command } from "commander";
|
||||
import { healthCommand } from "../../commands/health.js";
|
||||
import { sessionsCommand } from "../../commands/sessions.js";
|
||||
import { statusCommand } from "../../commands/status.js";
|
||||
import { setVerbose } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { parsePositiveIntOrUndefined } from "./helpers.js";
|
||||
|
||||
export function registerStatusHealthSessionsCommands(program: Command) {
|
||||
program
|
||||
.command("status")
|
||||
.description("Show channel health and recent session recipients")
|
||||
.option("--json", "Output JSON instead of text", false)
|
||||
.option("--all", "Full diagnosis (read-only, pasteable)", false)
|
||||
.option("--usage", "Show model provider usage/quota snapshots", false)
|
||||
.option(
|
||||
"--deep",
|
||||
"Probe channels (WhatsApp Web + Telegram + Discord + Slack + Signal)",
|
||||
false,
|
||||
)
|
||||
.option("--timeout <ms>", "Probe timeout in milliseconds", "10000")
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
.option("--debug", "Alias for --verbose", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
Examples:
|
||||
clawdbot status # show linked account + session store summary
|
||||
clawdbot status --all # full diagnosis (read-only)
|
||||
clawdbot status --json # machine-readable output
|
||||
clawdbot status --usage # show model provider usage/quota snapshots
|
||||
clawdbot status --deep # run channel probes (WA + Telegram + Discord + Slack + Signal)
|
||||
clawdbot status --deep --timeout 5000 # tighten probe timeout
|
||||
clawdbot channels status # gateway channel runtime + probes`,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
const verbose = Boolean(opts.verbose || opts.debug);
|
||||
setVerbose(verbose);
|
||||
const timeout = parsePositiveIntOrUndefined(opts.timeout);
|
||||
if (opts.timeout !== undefined && timeout === undefined) {
|
||||
defaultRuntime.error(
|
||||
"--timeout must be a positive integer (milliseconds)",
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await statusCommand(
|
||||
{
|
||||
json: Boolean(opts.json),
|
||||
all: Boolean(opts.all),
|
||||
deep: Boolean(opts.deep),
|
||||
usage: Boolean(opts.usage),
|
||||
timeoutMs: timeout,
|
||||
verbose,
|
||||
},
|
||||
defaultRuntime,
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("health")
|
||||
.description("Fetch health from the running gateway")
|
||||
.option("--json", "Output JSON instead of text", false)
|
||||
.option("--timeout <ms>", "Connection timeout in milliseconds", "10000")
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
.option("--debug", "Alias for --verbose", false)
|
||||
.action(async (opts) => {
|
||||
const verbose = Boolean(opts.verbose || opts.debug);
|
||||
setVerbose(verbose);
|
||||
const timeout = parsePositiveIntOrUndefined(opts.timeout);
|
||||
if (opts.timeout !== undefined && timeout === undefined) {
|
||||
defaultRuntime.error(
|
||||
"--timeout must be a positive integer (milliseconds)",
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await healthCommand(
|
||||
{
|
||||
json: Boolean(opts.json),
|
||||
timeoutMs: timeout,
|
||||
verbose,
|
||||
},
|
||||
defaultRuntime,
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("sessions")
|
||||
.description("List stored conversation sessions")
|
||||
.option("--json", "Output as JSON", false)
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
.option(
|
||||
"--store <path>",
|
||||
"Path to session store (default: resolved from config)",
|
||||
)
|
||||
.option(
|
||||
"--active <minutes>",
|
||||
"Only show sessions updated within the past N minutes",
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
Examples:
|
||||
clawdbot sessions # list all sessions
|
||||
clawdbot sessions --active 120 # only last 2 hours
|
||||
clawdbot sessions --json # machine-readable output
|
||||
clawdbot sessions --store ./tmp/sessions.json
|
||||
|
||||
Shows token usage per session when the agent reports it; set agents.defaults.contextTokens to see % of your model window.`,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
await sessionsCommand(
|
||||
{
|
||||
json: Boolean(opts.json),
|
||||
store: opts.store as string | undefined,
|
||||
active: opts.active as string | undefined,
|
||||
},
|
||||
defaultRuntime,
|
||||
);
|
||||
});
|
||||
}
|
||||
41
src/cli/program/register.subclis.ts
Normal file
41
src/cli/program/register.subclis.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Command } from "commander";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { registerPluginCliCommands } from "../../plugins/cli.js";
|
||||
import { registerChannelsCli } from "../channels-cli.js";
|
||||
import { registerCronCli } from "../cron-cli.js";
|
||||
import { registerDaemonCli } from "../daemon-cli.js";
|
||||
import { registerDnsCli } from "../dns-cli.js";
|
||||
import { registerDocsCli } from "../docs-cli.js";
|
||||
import { registerGatewayCli } from "../gateway-cli.js";
|
||||
import { registerHooksCli } from "../hooks-cli.js";
|
||||
import { registerLogsCli } from "../logs-cli.js";
|
||||
import { registerMemoryCli } from "../memory-cli.js";
|
||||
import { registerModelsCli } from "../models-cli.js";
|
||||
import { registerNodesCli } from "../nodes-cli.js";
|
||||
import { registerPairingCli } from "../pairing-cli.js";
|
||||
import { registerPluginsCli } from "../plugins-cli.js";
|
||||
import { registerSandboxCli } from "../sandbox-cli.js";
|
||||
import { registerSkillsCli } from "../skills-cli.js";
|
||||
import { registerTuiCli } from "../tui-cli.js";
|
||||
import { registerUpdateCli } from "../update-cli.js";
|
||||
|
||||
export function registerSubCliCommands(program: Command) {
|
||||
registerDaemonCli(program);
|
||||
registerGatewayCli(program);
|
||||
registerLogsCli(program);
|
||||
registerMemoryCli(program);
|
||||
registerModelsCli(program);
|
||||
registerNodesCli(program);
|
||||
registerSandboxCli(program);
|
||||
registerTuiCli(program);
|
||||
registerCronCli(program);
|
||||
registerDnsCli(program);
|
||||
registerDocsCli(program);
|
||||
registerHooksCli(program);
|
||||
registerPairingCli(program);
|
||||
registerPluginsCli(program);
|
||||
registerChannelsCli(program);
|
||||
registerSkillsCli(program);
|
||||
registerUpdateCli(program);
|
||||
registerPluginCliCommands(program, loadConfig());
|
||||
}
|
||||
Reference in New Issue
Block a user