@shreejitverma/x-axi 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +401 -2
- package/dist/bin/x-axi.js +4 -0
- package/dist/src/api.js +112 -0
- package/dist/src/approve.js +325 -0
- package/dist/src/args.js +201 -0
- package/dist/src/auth.js +419 -0
- package/dist/src/cli.js +94 -0
- package/dist/src/commands.js +1611 -0
- package/dist/src/config.js +67 -0
- package/dist/src/keystore.js +186 -0
- package/dist/src/ledger.js +172 -0
- package/dist/src/media.js +123 -0
- package/dist/src/oauth.js +149 -0
- package/dist/src/rates.js +47 -0
- package/dist/src/version.js +4 -0
- package/package.json +57 -4
- package/skills/x-axi/SKILL.md +69 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
3
|
+
const DEFAULT_TIMEOUT_SECONDS = 120;
|
|
4
|
+
// setTimeout fires at once for a delay over 2^31 - 1 milliseconds.
|
|
5
|
+
const MAX_TIMEOUT_SECONDS = 2_147_483;
|
|
6
|
+
const DIALOG_TITLE = "x-axi: approve this action";
|
|
7
|
+
// Room for a whole 280 character post after its "Replying to @handle: " prefix.
|
|
8
|
+
const MAX_UNWRAPPED_CONTEXT = 320;
|
|
9
|
+
export const SYSTEM_DIRECTORIES = ["/usr/bin", "/bin", "/usr/local/bin"];
|
|
10
|
+
// zenity comes first because it can make Cancel the default button and kdialog cannot.
|
|
11
|
+
const LINUX_TOOLS = ["zenity", "kdialog"];
|
|
12
|
+
// Arguments reach AppleScript through argv, so post text can never be parsed as script.
|
|
13
|
+
const SCRIPT = `on run argv
|
|
14
|
+
set answer to display dialog (item 1 of argv) with title "${DIALOG_TITLE}" buttons {"Cancel", "Approve"} default button "Cancel" cancel button "Cancel" with icon caution giving up after ((item 2 of argv) as integer)
|
|
15
|
+
if gave up of answer then return "timed_out"
|
|
16
|
+
return button returned of answer
|
|
17
|
+
end run`;
|
|
18
|
+
export function counted(count, noun) {
|
|
19
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
20
|
+
}
|
|
21
|
+
function approvalHead(request) {
|
|
22
|
+
const cost = request.estCostUsd === null ? "unknown" : `about $${request.estCostUsd.toFixed(3)}`;
|
|
23
|
+
return `${request.action} as @${request.account} (${cost})`;
|
|
24
|
+
}
|
|
25
|
+
// The tool writes the counts, so outgoing text cannot pass part of itself off as not sent.
|
|
26
|
+
function textLabel(text) {
|
|
27
|
+
return `Text to send, all of it (${counted(Array.from(text).length, "character")}, ${counted(text.split("\n").length, "line")}):`;
|
|
28
|
+
}
|
|
29
|
+
const CONTEXT_RULE = "----- context from X, not sent -----";
|
|
30
|
+
const cutMarker = (text) => `[cut to fit: ${counted(Array.from(text).length, "character")} in full]`;
|
|
31
|
+
/** The text for a dialog that has no line budget: macOS. */
|
|
32
|
+
export function formatApproval(request) {
|
|
33
|
+
const body = "text" in request ? [textLabel(request.text), request.text] : [request.content];
|
|
34
|
+
// The body comes first and context last, flattened, so text from X cannot pose as it.
|
|
35
|
+
const lines = [approvalHead(request), "", ...body];
|
|
36
|
+
if (request.context) {
|
|
37
|
+
const characters = Array.from(request.context);
|
|
38
|
+
const shown = characters.length <= MAX_UNWRAPPED_CONTEXT ? request.context : `${characters.slice(0, MAX_UNWRAPPED_CONTEXT).join("")}\n${cutMarker(request.context)}`;
|
|
39
|
+
lines.push("", CONTEXT_RULE, shown);
|
|
40
|
+
}
|
|
41
|
+
return lines.join("\n");
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Picks the dialog for this machine. Linux tools are looked up only in fixed
|
|
45
|
+
* system directories, never on PATH, so a PATH entry cannot answer the dialog.
|
|
46
|
+
*/
|
|
47
|
+
export function selectBackend(machine) {
|
|
48
|
+
if (machine.platform === "darwin")
|
|
49
|
+
return { kind: "osascript", path: "/usr/bin/osascript" };
|
|
50
|
+
if (machine.platform !== "linux")
|
|
51
|
+
return { kind: "none", reason: "unsupported_platform" };
|
|
52
|
+
if (!machine.env["DISPLAY"] && !machine.env["WAYLAND_DISPLAY"])
|
|
53
|
+
return { kind: "none", reason: "no_graphical_session" };
|
|
54
|
+
for (const kind of LINUX_TOOLS) {
|
|
55
|
+
for (const directory of machine.directories ?? SYSTEM_DIRECTORIES) {
|
|
56
|
+
const path = `${directory}/${kind}`;
|
|
57
|
+
if (machine.isExecutable(path))
|
|
58
|
+
return { kind, path };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { kind: "none", reason: "no_dialog_tool" };
|
|
62
|
+
}
|
|
63
|
+
export function isExecutableFile(path) {
|
|
64
|
+
try {
|
|
65
|
+
if (!statSync(path).isFile())
|
|
66
|
+
return false;
|
|
67
|
+
accessSync(path, constants.X_OK);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function approvalTimeoutSeconds(env) {
|
|
75
|
+
const requested = Number(env["X_AXI_APPROVAL_TIMEOUT"]);
|
|
76
|
+
return Number.isFinite(requested) && requested > 0 ? Math.min(Math.ceil(requested), MAX_TIMEOUT_SECONDS) : DEFAULT_TIMEOUT_SECONDS;
|
|
77
|
+
}
|
|
78
|
+
// GTK dialogs do not scroll on every zenity version, so an oversized one can push text and buttons off the screen.
|
|
79
|
+
// kdialog elides any word too wide for the screen, which would hide part of the text while still offering Approve.
|
|
80
|
+
// These limits are conservative estimates for a 1024x768 screen, not measurements of a real window.
|
|
81
|
+
const WRAP_CELLS = 50;
|
|
82
|
+
const MAX_WRAPPED_LINES = 25;
|
|
83
|
+
// Held back from the text whether or not this action has context, so context can never decide a refusal:
|
|
84
|
+
// one blank line, the context rule, and the context's cut marker.
|
|
85
|
+
const CONTEXT_RESERVE_LINES = 3;
|
|
86
|
+
const CONTINUES = "\u21b5";
|
|
87
|
+
const BREAKS_ANYWHERE = String.raw `\p{sc=Han}\p{sc=Hiragana}\p{sc=Katakana}\p{sc=Hangul}`;
|
|
88
|
+
const WIDE = new RegExp(`[${BREAKS_ANYWHERE}\\p{Extended_Pictographic}]`, "u");
|
|
89
|
+
const WRAP_UNITS = new RegExp(`[${BREAKS_ANYWHERE}]|[^\\s${BREAKS_ANYWHERE}]+|\\s+`, "gu");
|
|
90
|
+
const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
91
|
+
const cellsOf = (text) => Array.from(text).reduce((sum, ch) => sum + (WIDE.test(ch) ? 2 : 1), 0);
|
|
92
|
+
/** Splits a run too wide for one row, leaving each piece room for the continuation mark. */
|
|
93
|
+
function splitRun(run) {
|
|
94
|
+
const pieces = [];
|
|
95
|
+
let piece = "";
|
|
96
|
+
let used = 0;
|
|
97
|
+
for (const { segment } of GRAPHEMES.segment(run)) {
|
|
98
|
+
const cells = cellsOf(segment);
|
|
99
|
+
if (used > 0 && used + cells > WRAP_CELLS - 1) {
|
|
100
|
+
pieces.push(piece);
|
|
101
|
+
piece = "";
|
|
102
|
+
used = 0;
|
|
103
|
+
}
|
|
104
|
+
piece += segment;
|
|
105
|
+
used += cells;
|
|
106
|
+
}
|
|
107
|
+
pieces.push(piece);
|
|
108
|
+
return pieces;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Greedy line wrapping. Each row keeps what ends it, so joining the rows gives the text back,
|
|
112
|
+
* changed only by the marked breaks inside runs too wide for a row.
|
|
113
|
+
*/
|
|
114
|
+
function wrapRows(text) {
|
|
115
|
+
const rows = [];
|
|
116
|
+
let breaks = 0;
|
|
117
|
+
for (const line of text.split("\n")) {
|
|
118
|
+
let row = "";
|
|
119
|
+
let used = 0;
|
|
120
|
+
for (const unit of line.match(WRAP_UNITS) ?? []) {
|
|
121
|
+
const blank = !unit.trim();
|
|
122
|
+
const cells = cellsOf(unit);
|
|
123
|
+
if (used > 0 && used + cells > WRAP_CELLS) {
|
|
124
|
+
rows.push(blank ? row + unit : row); // Whitespace at a wrap point takes no room.
|
|
125
|
+
row = "";
|
|
126
|
+
used = 0;
|
|
127
|
+
if (blank)
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (!blank && cells > WRAP_CELLS) {
|
|
131
|
+
const pieces = splitRun(unit);
|
|
132
|
+
row = pieces.pop();
|
|
133
|
+
used = cellsOf(row);
|
|
134
|
+
rows.push(...pieces.map((piece) => `${piece}${CONTINUES}\n`));
|
|
135
|
+
breaks += pieces.length;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
row += unit;
|
|
139
|
+
used += cells;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
rows.push(`${row}\n`);
|
|
143
|
+
}
|
|
144
|
+
return { rows, breaks };
|
|
145
|
+
}
|
|
146
|
+
const joinRows = (rows) => rows.join("").slice(0, -1);
|
|
147
|
+
const rowCount = (text) => wrapRows(text).rows.length;
|
|
148
|
+
/** Text the user cannot change gets whatever rows remain, and is cut with a marker instead of ever causing a refusal. */
|
|
149
|
+
function clipToRows(text, maxRows) {
|
|
150
|
+
const { rows } = wrapRows(text);
|
|
151
|
+
if (rows.length <= maxRows)
|
|
152
|
+
return joinRows(rows);
|
|
153
|
+
const kept = rows.slice(0, maxRows - 1).join("").trimEnd();
|
|
154
|
+
return kept ? `${kept}\n${cutMarker(text)}` : cutMarker(text);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The text for a dialog that may not scroll, or null if the outgoing text cannot fit in it.
|
|
158
|
+
* Outgoing text must be shown in full to be approved, so it is the only thing that can cause a refusal.
|
|
159
|
+
*/
|
|
160
|
+
export function linuxApprovalText(request) {
|
|
161
|
+
const head = wrapRows(approvalHead(request));
|
|
162
|
+
const opening = [joinRows(head.rows), ""];
|
|
163
|
+
const marksNote = (breaks) => (breaks ? [`This dialog splits long words at ${counted(breaks, "mark")} (${CONTINUES}), which are not sent.`] : []);
|
|
164
|
+
const roomForBody = MAX_WRAPPED_LINES - CONTEXT_RESERVE_LINES;
|
|
165
|
+
let shown;
|
|
166
|
+
if ("text" in request) {
|
|
167
|
+
const { rows, breaks } = wrapRows(request.text);
|
|
168
|
+
shown = [...opening, textLabel(request.text), ...marksNote(head.breaks + breaks), joinRows(rows)].join("\n");
|
|
169
|
+
if (rowCount(shown) > roomForBody)
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
const intro = [...opening, ...marksNote(head.breaks)].join("\n");
|
|
174
|
+
shown = [intro, clipToRows(request.content, roomForBody - rowCount(intro))].join("\n");
|
|
175
|
+
}
|
|
176
|
+
if (!request.context)
|
|
177
|
+
return shown;
|
|
178
|
+
return [shown, "", CONTEXT_RULE, clipToRows(request.context, MAX_WRAPPED_LINES - rowCount(shown) - 2)].join("\n");
|
|
179
|
+
}
|
|
180
|
+
const KDIALOG_ESCAPES = { "&": "&", "<": "<", ">": ">", '"': """, "\\": "\", "\n": "<br>" };
|
|
181
|
+
/**
|
|
182
|
+
* kdialog has no plain text option: Qt guesses whether a label is rich text, and
|
|
183
|
+
* kdialog also expands backslash escapes. Always sending escaped rich text removes
|
|
184
|
+
* the guess, and a backslash entity never reaches the escape parser.
|
|
185
|
+
*/
|
|
186
|
+
export function kdialogText(text) {
|
|
187
|
+
const escaped = text.replace(/[&<>"\\\n]/g, (ch) => KDIALOG_ESCAPES[ch]);
|
|
188
|
+
return `<qt><div style="white-space: pre-wrap">${escaped}</div></qt>`;
|
|
189
|
+
}
|
|
190
|
+
function linuxDialogArgs(kind, text, timeout) {
|
|
191
|
+
if (kind === "zenity") {
|
|
192
|
+
// The --name=value form keeps text that starts with a dash from being read as a flag.
|
|
193
|
+
// With --no-markup, zenity neither parses Pango markup nor expands backslash escapes.
|
|
194
|
+
return [
|
|
195
|
+
"--question",
|
|
196
|
+
"--no-markup",
|
|
197
|
+
"--default-cancel",
|
|
198
|
+
`--title=${DIALOG_TITLE}`,
|
|
199
|
+
"--ok-label=Approve",
|
|
200
|
+
"--cancel-label=Cancel",
|
|
201
|
+
`--timeout=${timeout}`,
|
|
202
|
+
`--text=${text}`,
|
|
203
|
+
];
|
|
204
|
+
}
|
|
205
|
+
// kdialog has no flag for a timeout or for the default button.
|
|
206
|
+
return ["--title", DIALOG_TITLE, "--yes-label", "Approve", "--no-label", "Cancel", "--warningyesno", kdialogText(text)];
|
|
207
|
+
}
|
|
208
|
+
// A dialog tool reads the environment too. zenity takes its exit codes from ZENITY_OK, ZENITY_CANCEL, and the like,
|
|
209
|
+
// so ZENITY_CANCEL=0 would turn Cancel into approval, and LD_PRELOAD or GTK_MODULES would load code into it.
|
|
210
|
+
const DIALOG_ENV = [
|
|
211
|
+
"DISPLAY",
|
|
212
|
+
"WAYLAND_DISPLAY",
|
|
213
|
+
"XAUTHORITY",
|
|
214
|
+
"XDG_RUNTIME_DIR",
|
|
215
|
+
"XDG_SESSION_TYPE",
|
|
216
|
+
"XDG_CURRENT_DESKTOP",
|
|
217
|
+
"DBUS_SESSION_BUS_ADDRESS",
|
|
218
|
+
"HOME",
|
|
219
|
+
"LANG",
|
|
220
|
+
"LANGUAGE",
|
|
221
|
+
"LC_ALL",
|
|
222
|
+
"LC_CTYPE",
|
|
223
|
+
"LC_MESSAGES",
|
|
224
|
+
];
|
|
225
|
+
function dialogEnvironment(env) {
|
|
226
|
+
const kept = { PATH: SYSTEM_DIRECTORIES.join(":") };
|
|
227
|
+
for (const name of DIALOG_ENV) {
|
|
228
|
+
const value = env[name];
|
|
229
|
+
if (value !== undefined)
|
|
230
|
+
kept[name] = value;
|
|
231
|
+
}
|
|
232
|
+
return kept;
|
|
233
|
+
}
|
|
234
|
+
// GTK exits 1 when it cannot open the display, the same code as the Cancel button. GTK 3 and 4 do not translate these.
|
|
235
|
+
const GTK_DISPLAY_FAILURE = /cannot open display|failed to open display/i;
|
|
236
|
+
const MAX_STDERR_CHARS = 64 * 1024;
|
|
237
|
+
function linuxResult(tool, code, stderr) {
|
|
238
|
+
if (code === 0)
|
|
239
|
+
return "approved";
|
|
240
|
+
if (tool === "zenity" && code === 1 && GTK_DISPLAY_FAILURE.test(stderr))
|
|
241
|
+
return "unavailable";
|
|
242
|
+
if (code === 1)
|
|
243
|
+
return "cancelled";
|
|
244
|
+
if (tool === "zenity" && code === 5)
|
|
245
|
+
return "timed_out";
|
|
246
|
+
return "unavailable"; // A crash, a signal, or any other failure.
|
|
247
|
+
}
|
|
248
|
+
function askLinux(tool, path, text, env) {
|
|
249
|
+
const timeout = approvalTimeoutSeconds(env);
|
|
250
|
+
return new Promise((resolve) => {
|
|
251
|
+
let child;
|
|
252
|
+
try {
|
|
253
|
+
// No shell and no stdin: the answer is the exit code, and nothing can type into the tool.
|
|
254
|
+
child = spawn(path, linuxDialogArgs(tool, text, timeout), { stdio: ["ignore", "ignore", "pipe"], env: dialogEnvironment(env) });
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
resolve("unavailable"); // For example, a NUL in the text.
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
let stderr = "";
|
|
261
|
+
child.stderr.setEncoding("utf8");
|
|
262
|
+
child.stderr.on("data", (chunk) => {
|
|
263
|
+
if (stderr.length < MAX_STDERR_CHARS)
|
|
264
|
+
stderr += chunk;
|
|
265
|
+
});
|
|
266
|
+
// kdialog has no timeout of its own. The kill also covers a zenity that hangs or ignores its --timeout.
|
|
267
|
+
const timer = setTimeout(() => {
|
|
268
|
+
child.kill("SIGKILL");
|
|
269
|
+
resolve("timed_out");
|
|
270
|
+
}, timeout * 1000);
|
|
271
|
+
child.once("error", () => {
|
|
272
|
+
clearTimeout(timer);
|
|
273
|
+
resolve("unavailable");
|
|
274
|
+
});
|
|
275
|
+
// "close" rather than "exit", so all of stderr has arrived before the exit code is read.
|
|
276
|
+
child.once("close", (code) => {
|
|
277
|
+
clearTimeout(timer);
|
|
278
|
+
resolve(linuxResult(tool, code, stderr));
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
function askMac(path, text, env) {
|
|
283
|
+
return new Promise((resolve) => {
|
|
284
|
+
execFile(path, // Absolute, so a PATH entry cannot answer the dialog.
|
|
285
|
+
["-e", SCRIPT, text, String(approvalTimeoutSeconds(env))], (error, stdout, stderr) => {
|
|
286
|
+
if (!error) {
|
|
287
|
+
const answer = stdout.trim();
|
|
288
|
+
if (answer === "Approve")
|
|
289
|
+
resolve("approved");
|
|
290
|
+
else if (answer === "timed_out")
|
|
291
|
+
resolve("timed_out");
|
|
292
|
+
else
|
|
293
|
+
resolve("cancelled");
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
// AppleScript reports the Cancel button as user-canceled error -128.
|
|
297
|
+
resolve(stderr.includes("-128") ? "cancelled" : "unavailable");
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* The human gate for every write. It runs in the tool rather than in a harness
|
|
303
|
+
* hook so it holds under every agent harness and permission mode, and it fails
|
|
304
|
+
* closed: anything other than an explicit click on Approve stops the write.
|
|
305
|
+
* There is deliberately no terminal prompt or override, because an agent could answer those.
|
|
306
|
+
*/
|
|
307
|
+
export function createApprover(machine) {
|
|
308
|
+
return (request) => {
|
|
309
|
+
const backend = selectBackend(machine);
|
|
310
|
+
if (backend.kind === "none")
|
|
311
|
+
return Promise.resolve(backend.reason);
|
|
312
|
+
if (backend.kind === "osascript")
|
|
313
|
+
return askMac(backend.path, formatApproval(request), machine.env);
|
|
314
|
+
const text = linuxApprovalText(request);
|
|
315
|
+
if (text === null)
|
|
316
|
+
return Promise.resolve("too_long");
|
|
317
|
+
return askLinux(backend.kind, backend.path, text, machine.env);
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
export const approveWithDialog = createApprover({
|
|
321
|
+
platform: process.platform,
|
|
322
|
+
env: process.env,
|
|
323
|
+
isExecutable: isExecutableFile,
|
|
324
|
+
});
|
|
325
|
+
//# sourceMappingURL=approve.js.map
|
package/dist/src/args.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
/** Fails loud on unknown flags. `valueFlags` take a value and `boolFlags` do not. */
|
|
3
|
+
export function parseArgs(args, command, valueFlags, boolFlags = []) {
|
|
4
|
+
const parsed = { positional: [], flags: new Map() };
|
|
5
|
+
const valid = [...valueFlags, ...boolFlags].join(", ") || "none";
|
|
6
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
7
|
+
const arg = args[i];
|
|
8
|
+
if (arg === "--") {
|
|
9
|
+
parsed.positional.push(...args.slice(i + 1));
|
|
10
|
+
break;
|
|
11
|
+
}
|
|
12
|
+
if (!arg.startsWith("--")) {
|
|
13
|
+
parsed.positional.push(arg);
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
const eq = arg.indexOf("=");
|
|
17
|
+
const name = eq === -1 ? arg : arg.slice(0, eq);
|
|
18
|
+
if (boolFlags.includes(name) && eq === -1) {
|
|
19
|
+
parsed.flags.set(name, true);
|
|
20
|
+
}
|
|
21
|
+
else if (valueFlags.includes(name)) {
|
|
22
|
+
const value = eq === -1 ? args[++i] : arg.slice(eq + 1);
|
|
23
|
+
if (value === undefined)
|
|
24
|
+
throw usage(`${name} needs a value`);
|
|
25
|
+
// The last value winning would silently drop an earlier one, such as a media file.
|
|
26
|
+
if (parsed.flags.has(name))
|
|
27
|
+
throw usage(`${name} was given twice; \`${command}\` takes it once`);
|
|
28
|
+
parsed.flags.set(name, value);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
throw usage(`unknown flag ${name} for \`${command}\`; valid flags: ${valid}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
export function usage(message, suggestions = []) {
|
|
37
|
+
return new AxiError(message, "VALIDATION_ERROR", suggestions);
|
|
38
|
+
}
|
|
39
|
+
export function parseLimit(parsed, fallback, min, max) {
|
|
40
|
+
const raw = parsed.flags.get("--limit");
|
|
41
|
+
if (raw === undefined)
|
|
42
|
+
return fallback;
|
|
43
|
+
const limit = Number(raw);
|
|
44
|
+
if (!Number.isInteger(limit) || limit < min || limit > max) {
|
|
45
|
+
throw usage(`--limit must be an integer from ${min} to ${max}`);
|
|
46
|
+
}
|
|
47
|
+
return limit;
|
|
48
|
+
}
|
|
49
|
+
/** A dollar cap on what one command may spend. Undefined when the flag is absent. */
|
|
50
|
+
export function parseMaxSpend(parsed) {
|
|
51
|
+
const raw = parsed.flags.get("--max-spend");
|
|
52
|
+
if (raw === undefined)
|
|
53
|
+
return undefined;
|
|
54
|
+
const usd = Number(raw);
|
|
55
|
+
if (!Number.isFinite(usd) || usd <= 0)
|
|
56
|
+
throw usage("--max-spend must be a positive amount in US dollars, such as 2.50");
|
|
57
|
+
return usd;
|
|
58
|
+
}
|
|
59
|
+
/** Accepts a numeric post ID or a post URL. */
|
|
60
|
+
export function parsePostId(value) {
|
|
61
|
+
const match = value?.match(/^(?:https?:\/\/(?:www\.)?(?:x|twitter)\.com\/\w+\/status\/)?(\d{1,25})(?:[/?].*)?$/);
|
|
62
|
+
if (!match?.[1])
|
|
63
|
+
throw usage(`expected a post ID or URL, got: ${value ?? "nothing"}`);
|
|
64
|
+
return match[1];
|
|
65
|
+
}
|
|
66
|
+
/** Accepts a numeric List ID or a List URL such as https://x.com/i/lists/123. */
|
|
67
|
+
export function parseListId(value) {
|
|
68
|
+
const match = value?.match(/^(?:https?:\/\/(?:www\.)?(?:x|twitter)\.com\/i\/lists\/)?(\d{1,19})(?:[/?].*)?$/);
|
|
69
|
+
if (!match?.[1])
|
|
70
|
+
throw usage(`expected a List ID or URL such as https://x.com/i/lists/123, got: ${value ?? "nothing"}`);
|
|
71
|
+
return match[1];
|
|
72
|
+
}
|
|
73
|
+
export function parseHandle(value) {
|
|
74
|
+
const handle = value?.replace(/^@/, "") ?? "";
|
|
75
|
+
if (!/^\w{1,15}$/.test(handle))
|
|
76
|
+
throw usage(`expected an X handle, got: ${value ?? "nothing"}`);
|
|
77
|
+
return handle;
|
|
78
|
+
}
|
|
79
|
+
// Control and bidi-override characters could make the approval dialog show
|
|
80
|
+
// something other than what gets sent.
|
|
81
|
+
// This covers format, control, separator, private-use, and unassigned code points
|
|
82
|
+
// (tag characters included), plus blank fillers and hidden variation selectors.
|
|
83
|
+
const DECEPTIVE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Co}\p{Cn}\p{Cs}͏ᅟᅠ឴឵᠋-᠍⠀ㅤ︀-︎ᅠ\u{e0100}-\u{e01ef}]/u;
|
|
84
|
+
// Viramas are listed by script because JavaScript has no property for them.
|
|
85
|
+
const VIRAMAS = {
|
|
86
|
+
Devanagari: "\\u094d",
|
|
87
|
+
Bengali: "\\u09cd",
|
|
88
|
+
Gurmukhi: "\\u0a4d",
|
|
89
|
+
Gujarati: "\\u0acd",
|
|
90
|
+
Oriya: "\\u0b4d",
|
|
91
|
+
Tamil: "\\u0bcd",
|
|
92
|
+
Telugu: "\\u0c4d",
|
|
93
|
+
Kannada: "\\u0ccd",
|
|
94
|
+
Malayalam: "\\u0d4d",
|
|
95
|
+
Sinhala: "\\u0dca",
|
|
96
|
+
};
|
|
97
|
+
const RTL_SCRIPTS = ["Arabic", "Hebrew", "Syriac", "Thaana", "Nko", "Adlam", "Hanifi_Rohingya", "Mandaic", "Samaritan"];
|
|
98
|
+
const letterOf = (script) => `[\\p{L}&&\\p{sc=${script}}]`;
|
|
99
|
+
const RTL_LETTER = `[\\p{L}&&[${RTL_SCRIPTS.map((script) => `\\p{sc=${script}}`).join("")}]]`;
|
|
100
|
+
// The only places DECEPTIVE's characters are ordinary text. Everywhere else they stay refused,
|
|
101
|
+
// and bidi embeddings, overrides, and isolates have no exception, because they can reorder the dialog.
|
|
102
|
+
const ORDINARY_FORMAT = new RegExp(`(${[
|
|
103
|
+
// U+200D between two emoji, after any U+FE0F or skin tone.
|
|
104
|
+
String.raw `(?<=\p{Extended_Pictographic}[\u{fe0f}\u{1f3fb}-\u{1f3ff}]?)(?=\p{Extended_Pictographic})`,
|
|
105
|
+
// U+200D after a virama, or between a letter and its script's virama, as in the Bengali ra-phala.
|
|
106
|
+
`(?<=[${Object.values(VIRAMAS).join("")}])\\u200d`,
|
|
107
|
+
...Object.entries(VIRAMAS).map(([script, virama]) => `(?<=${letterOf(script)})\\u200d(?=${virama})`),
|
|
108
|
+
// U+200C between letters of one script that uses it. The left letter may carry marks, such as a virama.
|
|
109
|
+
...["Arabic", ...Object.keys(VIRAMAS)].map((script) => `(?<=${letterOf(script)}\\p{M}*)\\u200c(?=${letterOf(script)})`),
|
|
110
|
+
// Tag characters only in a well-formed subdivision flag, such as England's.
|
|
111
|
+
String.raw `(?<=\u{1f3f4})[\u{e0030}-\u{e0039}\u{e0061}-\u{e007a}]{3,7}\u{e007f}`,
|
|
112
|
+
// U+200E and U+200F only in text that has a right-to-left letter.
|
|
113
|
+
`[\\u200e\\u200f](?:(?<=${RTL_LETTER}[\\s\\S]*)|(?=[\\s\\S]*${RTL_LETTER}))`,
|
|
114
|
+
// Emoji newer than this runtime's Unicode tables.
|
|
115
|
+
String.raw `[\p{Cn}&&\p{Extended_Pictographic}]`,
|
|
116
|
+
].join("|")})`, "gv");
|
|
117
|
+
const MAX_APPROVABLE = 4000;
|
|
118
|
+
const MAX_APPROVABLE_LINES = 40;
|
|
119
|
+
// Three blank lines in a row, or a long run of spaces, could push text out of view in the dialog.
|
|
120
|
+
const HIDING_GAP = /(?:[^\S\n]*\n){4}|[^\S\n]{40}/;
|
|
121
|
+
const ACCEPTED_BLANK = /[\u200d-\u200f\p{M}]/gu;
|
|
122
|
+
/** Length is left to X because the limit depends on the account. */
|
|
123
|
+
export function parseOutgoingText(value) {
|
|
124
|
+
const visible = (value ?? "").replace(ACCEPTED_BLANK, "");
|
|
125
|
+
if (value === undefined || !visible.trim())
|
|
126
|
+
throw usage("the text must not be empty");
|
|
127
|
+
if (DECEPTIVE.test(value.replace(ORDINARY_FORMAT, "").replaceAll("\n", "")))
|
|
128
|
+
throw usage("the text contains invisible or control characters; remove them");
|
|
129
|
+
if (value.length > MAX_APPROVABLE || value.split("\n").length > MAX_APPROVABLE_LINES) {
|
|
130
|
+
throw usage(`the text is over ${MAX_APPROVABLE} characters or ${MAX_APPROVABLE_LINES} lines, too long to review in the approval dialog`);
|
|
131
|
+
}
|
|
132
|
+
if (HIDING_GAP.test(visible))
|
|
133
|
+
throw usage("the text has a long run of blank lines or spaces; shorten it");
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
/** Makes text from X safe to show in the approval dialog: one line, nothing invisible. */
|
|
137
|
+
export function displayText(value) {
|
|
138
|
+
return value
|
|
139
|
+
.replace(/\s+/gu, " ")
|
|
140
|
+
.split(ORDINARY_FORMAT)
|
|
141
|
+
.map((part, index) => (index % 2 === 1 ? part : Array.from(part, (ch) => (DECEPTIVE.test(ch) ? "\ufffd" : ch)).join("")))
|
|
142
|
+
.join("");
|
|
143
|
+
}
|
|
144
|
+
const MAX_SHOWN_PATH = 120;
|
|
145
|
+
/** Keeps both ends of a path, because the name of the file and where it sits are what identify it. */
|
|
146
|
+
export function shortenPath(path) {
|
|
147
|
+
const characters = Array.from(path);
|
|
148
|
+
if (characters.length <= MAX_SHOWN_PATH)
|
|
149
|
+
return path;
|
|
150
|
+
const end = MAX_SHOWN_PATH / 2;
|
|
151
|
+
return `${characters.slice(0, end).join("")} ...[${characters.length} characters in full]... ${characters.slice(-end).join("")}`;
|
|
152
|
+
}
|
|
153
|
+
export function onePositional(parsed, command) {
|
|
154
|
+
if (parsed.positional.length > 1) {
|
|
155
|
+
throw usage(`\`${command}\` takes one argument; quote it if it contains spaces`);
|
|
156
|
+
}
|
|
157
|
+
return parsed.positional[0];
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* One line of text X will publish that has a documented length range, such as a List name or a poll option.
|
|
161
|
+
* Characters are counted as code points; X may count some characters as two and refuses what it does not accept.
|
|
162
|
+
*/
|
|
163
|
+
export function parseShortText(value, what, max) {
|
|
164
|
+
const text = value ?? "";
|
|
165
|
+
const length = Array.from(text).length;
|
|
166
|
+
if (length < 1 || length > max)
|
|
167
|
+
throw usage(`${what} must be 1 to ${max} characters, got ${length}`);
|
|
168
|
+
if (/[\r\n]/.test(text))
|
|
169
|
+
throw usage(`${what} must be one line`);
|
|
170
|
+
try {
|
|
171
|
+
return parseOutgoingText(text);
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
throw usage(`${what}: ${error.message}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// From the create post reference: 2 to 4 options of 1 to 25 characters, open 5 to 10080 minutes.
|
|
178
|
+
const POLL_OPTIONS_MIN = 2;
|
|
179
|
+
const POLL_OPTIONS_MAX = 4;
|
|
180
|
+
const POLL_OPTION_MAX_CHARS = 25;
|
|
181
|
+
export const POLL_MINUTES_MIN = 5;
|
|
182
|
+
export const POLL_MINUTES_MAX = 10_080;
|
|
183
|
+
export const POLL_MINUTES_DEFAULT = 1440;
|
|
184
|
+
/** `--poll "yes|no|maybe"`: options separated by `|`, with the spaces around each trimmed. */
|
|
185
|
+
export function parsePollOptions(raw) {
|
|
186
|
+
const options = raw.split("|").map((option) => option.trim());
|
|
187
|
+
if (options.length < POLL_OPTIONS_MIN || options.length > POLL_OPTIONS_MAX) {
|
|
188
|
+
throw usage(`--poll needs ${POLL_OPTIONS_MIN} to ${POLL_OPTIONS_MAX} options separated by |, got ${options.length}`);
|
|
189
|
+
}
|
|
190
|
+
return options.map((option, index) => parseShortText(option, `poll option ${index + 1}`, POLL_OPTION_MAX_CHARS));
|
|
191
|
+
}
|
|
192
|
+
export function parsePollMinutes(raw) {
|
|
193
|
+
if (raw === undefined)
|
|
194
|
+
return POLL_MINUTES_DEFAULT;
|
|
195
|
+
const minutes = Number(raw);
|
|
196
|
+
if (!Number.isInteger(minutes) || minutes < POLL_MINUTES_MIN || minutes > POLL_MINUTES_MAX) {
|
|
197
|
+
throw usage(`--poll-minutes must be an integer from ${POLL_MINUTES_MIN} to ${POLL_MINUTES_MAX} (7 days)`);
|
|
198
|
+
}
|
|
199
|
+
return minutes;
|
|
200
|
+
}
|
|
201
|
+
//# sourceMappingURL=args.js.map
|