@ramusriram/versus 0.1.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/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +210 -0
- package/bin/versus.js +8 -0
- package/package.json +40 -0
- package/src/backends/gemini.js +57 -0
- package/src/backends/index.js +66 -0
- package/src/backends/mock.js +23 -0
- package/src/backends/ollama.js +29 -0
- package/src/backends/openai.js +40 -0
- package/src/cache.js +82 -0
- package/src/cli.js +341 -0
- package/src/config.js +30 -0
- package/src/engine.js +143 -0
- package/src/introspect.js +165 -0
- package/src/prompt.js +57 -0
- package/src/status.js +125 -0
- package/src/util/argv.js +16 -0
- package/src/util/markdown.js +206 -0
- package/src/util/sanitize.js +28 -0
- package/src/util/spinner.js +47 -0
- package/src/util/style.js +46 -0
- package/src/util/text.js +61 -0
- package/src/util/time.js +93 -0
- package/src/util/timing.js +7 -0
- package/src/util/view.js +107 -0
- package/test/argv.test.js +12 -0
- package/test/markdown.test.js +32 -0
- package/test/prompt.test.js +20 -0
- package/test/sanitize.test.js +16 -0
- package/test/text.test.js +16 -0
- package/test/time.test.js +12 -0
package/src/prompt.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export function buildPrompt({
|
|
2
|
+
left,
|
|
3
|
+
right,
|
|
4
|
+
leftDocs,
|
|
5
|
+
rightDocs,
|
|
6
|
+
level = "intermediate",
|
|
7
|
+
mode = "summary",
|
|
8
|
+
}) {
|
|
9
|
+
const audience =
|
|
10
|
+
level === "beginner"
|
|
11
|
+
? "Beginner (use simple language, minimal jargon, short explanations)."
|
|
12
|
+
: level === "advanced"
|
|
13
|
+
? "Advanced (assume Linux comfort; include nuanced tradeoffs/perf details when relevant)."
|
|
14
|
+
: "Intermediate (developer-friendly; explain jargon once).";
|
|
15
|
+
|
|
16
|
+
const modeGuide =
|
|
17
|
+
mode === "cheatsheet"
|
|
18
|
+
? "Include practical flags and a few example commands. Keep it under ~450 words."
|
|
19
|
+
: mode === "table"
|
|
20
|
+
? "Prioritize a clear comparison table (4–8 rows) and keep other text short."
|
|
21
|
+
: "Keep it concise (under ~250 words).";
|
|
22
|
+
|
|
23
|
+
const docsNote =
|
|
24
|
+
leftDocs?.trim() || rightDocs?.trim()
|
|
25
|
+
? "Use the provided docs as the most reliable source. If the docs are missing or unclear, say so and then use general knowledge."
|
|
26
|
+
: "No docs were provided; use general knowledge, but be explicit that you are not grounded in local docs.";
|
|
27
|
+
|
|
28
|
+
return [
|
|
29
|
+
"You are a Linux expert and a careful technical writer.",
|
|
30
|
+
"",
|
|
31
|
+
`Task: Compare "${left}" and "${right}" for a developer.`,
|
|
32
|
+
`Audience: ${audience}`,
|
|
33
|
+
`Mode: ${mode}. ${modeGuide}`,
|
|
34
|
+
"",
|
|
35
|
+
"Output format: Markdown.",
|
|
36
|
+
"Rules:",
|
|
37
|
+
`- ${docsNote}`,
|
|
38
|
+
"- Do NOT invent flags that look plausible. If you are unsure, say you are unsure.",
|
|
39
|
+
"- Avoid fluff. Be helpful and specific.",
|
|
40
|
+
"",
|
|
41
|
+
"Required structure:",
|
|
42
|
+
"1) One-line verdict",
|
|
43
|
+
"2) Short overview",
|
|
44
|
+
`3) When to use ${left}`,
|
|
45
|
+
`4) When to use ${right}`,
|
|
46
|
+
"5) Key differences table",
|
|
47
|
+
"6) Examples (2–3 per side) if you can do so safely",
|
|
48
|
+
"7) Common mistakes / gotchas",
|
|
49
|
+
"",
|
|
50
|
+
`--- DOCS: ${left} ---`,
|
|
51
|
+
leftDocs?.trim() ? leftDocs.trim() : "[no local docs found]",
|
|
52
|
+
"",
|
|
53
|
+
`--- DOCS: ${right} ---`,
|
|
54
|
+
rightDocs?.trim() ? rightDocs.trim() : "[no local docs found]",
|
|
55
|
+
"",
|
|
56
|
+
].join("\n");
|
|
57
|
+
}
|
package/src/status.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { getCacheInfo } from "./cache.js";
|
|
3
|
+
import { pc } from "./util/style.js";
|
|
4
|
+
|
|
5
|
+
function run(cmd, args, { timeoutMs = 1200 } = {}) {
|
|
6
|
+
return new Promise((resolve) => {
|
|
7
|
+
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
8
|
+
|
|
9
|
+
let stdout = "";
|
|
10
|
+
let done = false;
|
|
11
|
+
|
|
12
|
+
const timer =
|
|
13
|
+
timeoutMs && timeoutMs > 0
|
|
14
|
+
? setTimeout(() => {
|
|
15
|
+
if (done) return;
|
|
16
|
+
done = true;
|
|
17
|
+
try {
|
|
18
|
+
child.kill("SIGKILL");
|
|
19
|
+
} catch {}
|
|
20
|
+
resolve({ ok: false, stdout, timedOut: true });
|
|
21
|
+
}, timeoutMs)
|
|
22
|
+
: null;
|
|
23
|
+
|
|
24
|
+
child.stdout.on("data", (d) => (stdout += d.toString("utf8")));
|
|
25
|
+
child.on("error", () => {
|
|
26
|
+
if (done) return;
|
|
27
|
+
done = true;
|
|
28
|
+
if (timer) clearTimeout(timer);
|
|
29
|
+
resolve({ ok: false, stdout: "" });
|
|
30
|
+
});
|
|
31
|
+
child.on("close", (code) => {
|
|
32
|
+
if (done) return;
|
|
33
|
+
done = true;
|
|
34
|
+
if (timer) clearTimeout(timer);
|
|
35
|
+
resolve({ ok: code === 0, stdout });
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function checkMan() {
|
|
41
|
+
// man can return non-zero on some systems depending on pager, so we just check it outputs something.
|
|
42
|
+
const res = await run("man", ["-P", "cat", "ls"], { timeoutMs: 1500 });
|
|
43
|
+
return Boolean(res.stdout && res.stdout.length > 20);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function checkOllama() {
|
|
47
|
+
const base = process.env.OLLAMA_BASE_URL || "http://localhost:11434";
|
|
48
|
+
const baseNoSlash = base.replace(/\/$/, "");
|
|
49
|
+
const versionUrl = `${baseNoSlash}/api/version`;
|
|
50
|
+
const tagsUrl = `${baseNoSlash}/api/tags`;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const vRes = await fetch(versionUrl);
|
|
54
|
+
if (!vRes.ok) return { ok: false, base, reason: `HTTP ${vRes.status}` };
|
|
55
|
+
const v = await vRes.json().catch(() => ({}));
|
|
56
|
+
|
|
57
|
+
const tRes = await fetch(tagsUrl);
|
|
58
|
+
const tags = tRes.ok ? await tRes.json().catch(() => ({})) : {};
|
|
59
|
+
const models = Array.isArray(tags?.models)
|
|
60
|
+
? tags.models.map((m) => m.name).slice(0, 10)
|
|
61
|
+
: [];
|
|
62
|
+
|
|
63
|
+
return { ok: true, base, version: v?.version, models };
|
|
64
|
+
} catch (e) {
|
|
65
|
+
return { ok: false, base, reason: "not reachable" };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function runStatus() {
|
|
70
|
+
const node = process.versions.node;
|
|
71
|
+
const major = Number(node.split(".")[0] || 0);
|
|
72
|
+
const nodeOk = major >= 20;
|
|
73
|
+
|
|
74
|
+
const manOk = await checkMan().catch(() => false);
|
|
75
|
+
const cacheInfo = await getCacheInfo();
|
|
76
|
+
|
|
77
|
+
const openaiKey = Boolean(process.env.OPENAI_API_KEY);
|
|
78
|
+
const geminiKey = Boolean(process.env.GEMINI_API_KEY);
|
|
79
|
+
const ollama = await checkOllama();
|
|
80
|
+
|
|
81
|
+
const ok = nodeOk && manOk;
|
|
82
|
+
|
|
83
|
+
const human = [];
|
|
84
|
+
human.push(pc.bold("versus status"));
|
|
85
|
+
human.push("");
|
|
86
|
+
|
|
87
|
+
human.push(`${nodeOk ? pc.green("✔") : pc.red("✖")} Node.js ${node} (need 20+)`);
|
|
88
|
+
human.push(`${manOk ? pc.green("✔") : pc.red("✖")} man pages available`);
|
|
89
|
+
|
|
90
|
+
human.push(`${pc.green("ℹ")} Cache file: ${cacheInfo.file}`);
|
|
91
|
+
human.push(`${pc.green("ℹ")} Cache entries: ${cacheInfo.entries}`);
|
|
92
|
+
human.push("");
|
|
93
|
+
|
|
94
|
+
human.push(pc.bold("Backends"));
|
|
95
|
+
human.push(`${openaiKey ? pc.green("✔") : pc.yellow("•")} OpenAI key ${openaiKey ? "set" : "not set"} (OPENAI_API_KEY)`);
|
|
96
|
+
human.push(`${geminiKey ? pc.green("✔") : pc.yellow("•")} Gemini key ${geminiKey ? "set" : "not set"} (GEMINI_API_KEY)`);
|
|
97
|
+
if (ollama.ok) {
|
|
98
|
+
const models = ollama.models?.length ? `models: ${ollama.models.join(", ")}` : "no models found (try: ollama pull llama3.2)";
|
|
99
|
+
human.push(`${pc.green("✔")} Ollama reachable at ${ollama.base} (${models})`);
|
|
100
|
+
} else {
|
|
101
|
+
human.push(`${pc.yellow("•")} Ollama not reachable at ${ollama.base}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
human.push("");
|
|
105
|
+
human.push(pc.dim("Tip: set OPENAI_API_KEY or GEMINI_API_KEY, or run Ollama locally to get real answers."));
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
ok,
|
|
109
|
+
checks: {
|
|
110
|
+
node: { ok: nodeOk, version: node, required: ">=20" },
|
|
111
|
+
man: { ok: manOk },
|
|
112
|
+
cache: cacheInfo,
|
|
113
|
+
backends: {
|
|
114
|
+
openai: { ok: openaiKey },
|
|
115
|
+
gemini: { ok: geminiKey },
|
|
116
|
+
ollama,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
human,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
// Backwards-compatible alias (older docs used `doctor`).
|
|
125
|
+
export const runDoctor = runStatus;
|
package/src/util/argv.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Small helpers for working with raw argv.
|
|
2
|
+
// We deliberately support both "--flag value" and "--flag=value" styles.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Returns true if any of the given flags appear in argv.
|
|
6
|
+
* Supports:
|
|
7
|
+
* --flag
|
|
8
|
+
* --flag=value
|
|
9
|
+
* -f
|
|
10
|
+
* -f=value
|
|
11
|
+
*/
|
|
12
|
+
export function hasAnyFlag(argv, flags) {
|
|
13
|
+
return flags.some((flag) =>
|
|
14
|
+
argv.some((arg) => arg === flag || arg.startsWith(`${flag}=`))
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { pc } from "./style.js";
|
|
2
|
+
|
|
3
|
+
import { stripAnsi } from "./text.js";
|
|
4
|
+
|
|
5
|
+
// IMPORTANT: do not capture styling functions at module load time.
|
|
6
|
+
// `pc` is a live binding that can be reconfigured at runtime (e.g. `--color never`).
|
|
7
|
+
const IDENTITY = (s) => s;
|
|
8
|
+
const style = (name) => (s) => (typeof pc?.[name] === "function" ? pc[name](s) : IDENTITY(s));
|
|
9
|
+
|
|
10
|
+
const bold = style("bold");
|
|
11
|
+
const dim = style("dim");
|
|
12
|
+
const underline = style("underline");
|
|
13
|
+
const italic = style("italic");
|
|
14
|
+
const cyan = style("cyan");
|
|
15
|
+
|
|
16
|
+
// Inline code should be readable but not visually loud across themes.
|
|
17
|
+
// Avoid inverse/background blocks (they can look harsh on dark themes).
|
|
18
|
+
const codeSpan = (s) => dim(underline(s));
|
|
19
|
+
|
|
20
|
+
function padAnsiRight(text, width) {
|
|
21
|
+
const len = stripAnsi(text).length;
|
|
22
|
+
if (len >= width) return text;
|
|
23
|
+
return text + " ".repeat(width - len);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseTableRow(line) {
|
|
27
|
+
let t = line.trim();
|
|
28
|
+
if (t.startsWith("|")) t = t.slice(1);
|
|
29
|
+
if (t.endsWith("|")) t = t.slice(0, -1);
|
|
30
|
+
return t.split("|").map((c) => c.trim());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isTableSeparator(line) {
|
|
34
|
+
const t = line.trim();
|
|
35
|
+
if (!t.includes("|")) return false;
|
|
36
|
+
// Separator is mostly pipes, dashes, colons and spaces.
|
|
37
|
+
return /^[|:\-\s]+$/.test(t) && t.includes("-");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderInline(text) {
|
|
41
|
+
const input = String(text ?? "");
|
|
42
|
+
|
|
43
|
+
// Code spans: split on backticks and style odd segments.
|
|
44
|
+
const parts = input.split("`");
|
|
45
|
+
const out = parts.map((seg, idx) => {
|
|
46
|
+
if (idx % 2 === 1) return codeSpan(seg);
|
|
47
|
+
|
|
48
|
+
let s = seg;
|
|
49
|
+
|
|
50
|
+
// Links: [text](url) -> underlined text (keep url in dim parens)
|
|
51
|
+
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label, url) => {
|
|
52
|
+
const cleanLabel = String(label);
|
|
53
|
+
const cleanUrl = String(url);
|
|
54
|
+
return `${underline(cleanLabel)}${dim(` (${cleanUrl})`)}`;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Bold: **text** or __text__
|
|
58
|
+
s = s.replace(/(\*\*|__)(.+?)\1/g, (_m, _d, inner) => bold(inner));
|
|
59
|
+
|
|
60
|
+
// Italic-ish: *text* or _text_ (avoid matching inside words too aggressively)
|
|
61
|
+
s = s.replace(/(^|[^\w])(\*|_)([^*_]+?)\2([^\w]|$)/g, (_m, pre, _d, inner, post) => {
|
|
62
|
+
// Many terminals render italics inconsistently. Underline is the safest emphasis.
|
|
63
|
+
return `${pre}${underline(inner)}${post}`;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return s;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return out.join("");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function renderTable(lines, startIdx) {
|
|
73
|
+
const header = parseTableRow(lines[startIdx]);
|
|
74
|
+
const rows = [header];
|
|
75
|
+
|
|
76
|
+
let i = startIdx + 2; // skip header + separator
|
|
77
|
+
while (i < lines.length) {
|
|
78
|
+
const line = lines[i];
|
|
79
|
+
if (!line.trim()) break;
|
|
80
|
+
if (!line.includes("|")) break;
|
|
81
|
+
rows.push(parseTableRow(line));
|
|
82
|
+
i++;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const cols = Math.max(...rows.map((r) => r.length));
|
|
86
|
+
for (const r of rows) {
|
|
87
|
+
while (r.length < cols) r.push("");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Pre-render cells so we can measure visible widths.
|
|
91
|
+
const rendered = rows.map((r, rowIdx) =>
|
|
92
|
+
r.map((c) => {
|
|
93
|
+
const cell = renderInline(c);
|
|
94
|
+
return rowIdx === 0 ? bold(cell) : cell;
|
|
95
|
+
})
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const widths = new Array(cols).fill(0);
|
|
99
|
+
for (const r of rendered) {
|
|
100
|
+
r.forEach((cell, idx) => {
|
|
101
|
+
const w = stripAnsi(cell).length;
|
|
102
|
+
if (w > widths[idx]) widths[idx] = w;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const border = "+" + widths.map((w) => "-".repeat(w + 2)).join("+") + "+";
|
|
107
|
+
|
|
108
|
+
const out = [];
|
|
109
|
+
out.push(border);
|
|
110
|
+
for (let rowIdx = 0; rowIdx < rendered.length; rowIdx++) {
|
|
111
|
+
const row = rendered[rowIdx];
|
|
112
|
+
const line =
|
|
113
|
+
"|" +
|
|
114
|
+
row
|
|
115
|
+
.map((cell, idx) => {
|
|
116
|
+
const padded = padAnsiRight(cell, widths[idx]);
|
|
117
|
+
return ` ${padded} `;
|
|
118
|
+
})
|
|
119
|
+
.join("|") +
|
|
120
|
+
"|";
|
|
121
|
+
out.push(line);
|
|
122
|
+
if (rowIdx === 0) out.push(border);
|
|
123
|
+
}
|
|
124
|
+
out.push(border);
|
|
125
|
+
|
|
126
|
+
return { renderedLines: out, nextIdx: i };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Render Markdown into a terminal-friendly string.
|
|
131
|
+
*
|
|
132
|
+
* If stdout is not a TTY, you should generally skip rendering and print raw Markdown.
|
|
133
|
+
*/
|
|
134
|
+
export function renderMarkdownToTerminal(markdown) {
|
|
135
|
+
const md = String(markdown ?? "");
|
|
136
|
+
const lines = md.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
137
|
+
const out = [];
|
|
138
|
+
|
|
139
|
+
let inCode = false;
|
|
140
|
+
for (let idx = 0; idx < lines.length; idx++) {
|
|
141
|
+
const line = lines[idx];
|
|
142
|
+
const trimmed = line.trim();
|
|
143
|
+
|
|
144
|
+
// Fenced code blocks
|
|
145
|
+
if (trimmed.startsWith("```")) {
|
|
146
|
+
inCode = !inCode;
|
|
147
|
+
out.push(dim(trimmed));
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (inCode) {
|
|
152
|
+
out.push(dim(line));
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Markdown tables
|
|
157
|
+
if (line.includes("|") && idx + 1 < lines.length && isTableSeparator(lines[idx + 1])) {
|
|
158
|
+
const table = renderTable(lines, idx);
|
|
159
|
+
out.push(...table.renderedLines);
|
|
160
|
+
idx = table.nextIdx - 1;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Headings
|
|
165
|
+
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
|
166
|
+
if (h) {
|
|
167
|
+
const text = h[2].trim();
|
|
168
|
+
out.push(bold(cyan(text)));
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Horizontal rule
|
|
173
|
+
if (/^\s*([-*_])\1\1+\s*$/.test(trimmed)) {
|
|
174
|
+
out.push(dim("─".repeat(40)));
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Blockquote
|
|
179
|
+
const bq = line.match(/^\s*>\s?(.*)$/);
|
|
180
|
+
if (bq) {
|
|
181
|
+
out.push(dim(`│ ${bq[1]}`));
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Unordered list
|
|
186
|
+
const ul = line.match(/^(\s*)([-*+])\s+(.*)$/);
|
|
187
|
+
if (ul) {
|
|
188
|
+
const indent = ul[1] || "";
|
|
189
|
+
out.push(`${indent}• ${renderInline(ul[3])}`);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Ordered list
|
|
194
|
+
const ol = line.match(/^(\s*)(\d+)\.\s+(.*)$/);
|
|
195
|
+
if (ol) {
|
|
196
|
+
const indent = ol[1] || "";
|
|
197
|
+
out.push(`${indent}${ol[2]}. ${renderInline(ol[3])}`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Regular line
|
|
202
|
+
out.push(renderInline(line));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return out.join("\n");
|
|
206
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const SAFE_TOKEN = /^[A-Za-z0-9][A-Za-z0-9._+:-]*$/;
|
|
2
|
+
|
|
3
|
+
export function tokenizeTarget(target) {
|
|
4
|
+
return String(target).trim().split(/\s+/).filter(Boolean);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function isSafeToken(token) {
|
|
8
|
+
// Disallow shell metacharacters, quotes, spaces, etc.
|
|
9
|
+
// Allow typical command names and subcommands: git, pull, docker-compose, ip, ss, etc.
|
|
10
|
+
return SAFE_TOKEN.test(token);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function validateTargetForExec(target) {
|
|
14
|
+
const tokens = tokenizeTarget(target);
|
|
15
|
+
if (tokens.length === 0) {
|
|
16
|
+
return { ok: false, reason: "Empty target" };
|
|
17
|
+
}
|
|
18
|
+
if (!isSafeToken(tokens[0])) {
|
|
19
|
+
return { ok: false, reason: `Unsafe command token: "${tokens[0]}"` };
|
|
20
|
+
}
|
|
21
|
+
for (const t of tokens.slice(1)) {
|
|
22
|
+
// Allow subcommand tokens, but still keep them safe.
|
|
23
|
+
if (!isSafeToken(t)) {
|
|
24
|
+
return { ok: false, reason: `Unsafe token: "${t}"` };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { ok: true, tokens };
|
|
28
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Tiny terminal spinner (animated dots) for long-running operations.
|
|
2
|
+
// Writes to stderr so stdout stays clean for piping.
|
|
3
|
+
|
|
4
|
+
export function createSpinner({ text = "Working", delayMs = 150, intervalMs = 120 } = {}) {
|
|
5
|
+
// Only show when stderr is a real TTY.
|
|
6
|
+
if (!process.stderr.isTTY) {
|
|
7
|
+
return {
|
|
8
|
+
update: () => {},
|
|
9
|
+
stop: () => {},
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const frames = ["", ".", "..", "..."];
|
|
14
|
+
let frameIdx = 0;
|
|
15
|
+
let lastLen = 0;
|
|
16
|
+
let interval = null;
|
|
17
|
+
let started = false;
|
|
18
|
+
|
|
19
|
+
const writeFrame = () => {
|
|
20
|
+
const msg = `${text}${frames[frameIdx]}`;
|
|
21
|
+
frameIdx = (frameIdx + 1) % frames.length;
|
|
22
|
+
|
|
23
|
+
// Clear any leftover characters from longer previous frames.
|
|
24
|
+
const pad = lastLen > msg.length ? " ".repeat(lastLen - msg.length) : "";
|
|
25
|
+
lastLen = msg.length;
|
|
26
|
+
process.stderr.write(`\r${msg}${pad}`);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const timer = setTimeout(() => {
|
|
30
|
+
started = true;
|
|
31
|
+
writeFrame();
|
|
32
|
+
interval = setInterval(writeFrame, intervalMs);
|
|
33
|
+
}, delayMs);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
update(nextText) {
|
|
37
|
+
text = String(nextText ?? "");
|
|
38
|
+
},
|
|
39
|
+
stop() {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
if (interval) clearInterval(interval);
|
|
42
|
+
if (started) {
|
|
43
|
+
process.stderr.write(`\r${" ".repeat(lastLen)}\r`);
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import picocolors from "picocolors";
|
|
2
|
+
|
|
3
|
+
// Shared styling layer so we can honor `--color` and `NO_COLOR`
|
|
4
|
+
// consistently across the CLI.
|
|
5
|
+
//
|
|
6
|
+
// `pc` is a live binding (reassigned by setColorMode). Other modules import it
|
|
7
|
+
// and will automatically see the updated object.
|
|
8
|
+
|
|
9
|
+
export let pc = picocolors;
|
|
10
|
+
|
|
11
|
+
function normalizeMode(mode) {
|
|
12
|
+
const m = String(mode ?? "auto").trim().toLowerCase();
|
|
13
|
+
if (m === "always" || m === "on" || m === "true" || m === "1") return "always";
|
|
14
|
+
if (m === "never" || m === "off" || m === "false" || m === "0") return "never";
|
|
15
|
+
return "auto";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Configure ANSI color/styling.
|
|
20
|
+
*
|
|
21
|
+
* Supported modes:
|
|
22
|
+
* - auto: enable when attached to a TTY and NO_COLOR is not set
|
|
23
|
+
* - always: force-enable
|
|
24
|
+
* - never: force-disable
|
|
25
|
+
*/
|
|
26
|
+
export function setColorMode(mode, { stdoutIsTTY = process.stdout.isTTY, stderrIsTTY = process.stderr.isTTY } = {}) {
|
|
27
|
+
const m = normalizeMode(mode);
|
|
28
|
+
|
|
29
|
+
const noColorEnv = Object.prototype.hasOwnProperty.call(process.env, "NO_COLOR");
|
|
30
|
+
const forceColorEnv = process.env.FORCE_COLOR;
|
|
31
|
+
const isTTY = Boolean(stdoutIsTTY || stderrIsTTY);
|
|
32
|
+
|
|
33
|
+
let enabled;
|
|
34
|
+
if (m === "always") {
|
|
35
|
+
enabled = true;
|
|
36
|
+
} else if (m === "never") {
|
|
37
|
+
enabled = false;
|
|
38
|
+
} else {
|
|
39
|
+
// auto
|
|
40
|
+
enabled = isTTY && !noColorEnv && forceColorEnv !== "0";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// picocolors exposes createColors(enabled) which returns an object containing
|
|
44
|
+
// the same styling functions but conditionally enabled.
|
|
45
|
+
pc = typeof picocolors.createColors === "function" ? picocolors.createColors(enabled) : picocolors;
|
|
46
|
+
}
|
package/src/util/text.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export function sha256(text) {
|
|
4
|
+
return crypto.createHash("sha256").update(text).digest("hex");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function stripAnsi(text) {
|
|
8
|
+
// Basic ANSI escape stripping
|
|
9
|
+
return text.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function stripOverstrikes(text) {
|
|
13
|
+
// man pages often use overstrike/backspace formatting: "H\bH" or "_\bX".
|
|
14
|
+
// This removes any "char + backspace" sequences.
|
|
15
|
+
// Example: "m\ban" -> "an" (approx).
|
|
16
|
+
return text.replace(/.\x08/g, "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function normalizeText(text) {
|
|
20
|
+
let t = String(text ?? "");
|
|
21
|
+
t = t.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
22
|
+
t = stripAnsi(t);
|
|
23
|
+
t = stripOverstrikes(t);
|
|
24
|
+
// Collapse absurd whitespace while keeping newlines.
|
|
25
|
+
t = t.replace(/\n{3,}/g, "\n\n");
|
|
26
|
+
t = t.replace(/[ \t]{2,}/g, " ");
|
|
27
|
+
return t.trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function truncate(text, maxChars) {
|
|
31
|
+
const t = String(text ?? "");
|
|
32
|
+
if (!Number.isFinite(maxChars) || maxChars <= 0) return "";
|
|
33
|
+
if (t.length <= maxChars) return t;
|
|
34
|
+
return t.slice(0, maxChars) + "\n\n[...truncated...]\n";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Truncate without chopping words in half (when possible).
|
|
39
|
+
*
|
|
40
|
+
* If we can't find a whitespace boundary (e.g. a very long token), we fall back
|
|
41
|
+
* to a hard character cut.
|
|
42
|
+
*/
|
|
43
|
+
export function truncateAtWordBoundary(text, maxChars, { suffix = "\n\n[...truncated...]\n" } = {}) {
|
|
44
|
+
const t = String(text ?? "");
|
|
45
|
+
if (!Number.isFinite(maxChars) || maxChars <= 0) return "";
|
|
46
|
+
if (t.length <= maxChars) return t;
|
|
47
|
+
|
|
48
|
+
// Look for a whitespace boundary near the cut point.
|
|
49
|
+
const window = t.slice(0, maxChars + 1);
|
|
50
|
+
const lastWs = Math.max(
|
|
51
|
+
window.lastIndexOf(" "),
|
|
52
|
+
window.lastIndexOf("\n"),
|
|
53
|
+
window.lastIndexOf("\t")
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// If we found any whitespace boundary, prefer it to avoid chopping words.
|
|
57
|
+
const cut = lastWs >= 0 ? lastWs : maxChars;
|
|
58
|
+
|
|
59
|
+
return t.slice(0, cut).trimEnd() + suffix;
|
|
60
|
+
}
|
|
61
|
+
|
package/src/util/time.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Time formatting helpers for user-facing UX.
|
|
2
|
+
|
|
3
|
+
function pad2(n) {
|
|
4
|
+
return String(n).padStart(2, "0");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function formatUtcOffsetLabel(date) {
|
|
8
|
+
// getTimezoneOffset() returns minutes behind UTC (e.g., IST => -330)
|
|
9
|
+
const offsetMin = -date.getTimezoneOffset();
|
|
10
|
+
const sign = offsetMin >= 0 ? "+" : "-";
|
|
11
|
+
const abs = Math.abs(offsetMin);
|
|
12
|
+
const hh = pad2(Math.floor(abs / 60));
|
|
13
|
+
const mm = pad2(abs % 60);
|
|
14
|
+
return `UTC${sign}${hh}:${mm}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function resolveTimeZoneLabel(date) {
|
|
18
|
+
// Best effort: try to get a friendly short name from Intl (e.g., IST, PST).
|
|
19
|
+
// If ICU data is limited, this may fall back to GMT offsets.
|
|
20
|
+
try {
|
|
21
|
+
const parts = new Intl.DateTimeFormat(undefined, { timeZoneName: "short" }).formatToParts(date);
|
|
22
|
+
const tzPart = parts.find((p) => p.type === "timeZoneName");
|
|
23
|
+
if (tzPart?.value) return tzPart.value;
|
|
24
|
+
} catch {
|
|
25
|
+
// ignore
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Special-case common one: Asia/Kolkata.
|
|
29
|
+
try {
|
|
30
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
31
|
+
if (tz === "Asia/Kolkata" || tz === "Asia/Calcutta") return "IST";
|
|
32
|
+
} catch {
|
|
33
|
+
// ignore
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return formatUtcOffsetLabel(date);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function formatLocalDateTime(date) {
|
|
40
|
+
const base = new Intl.DateTimeFormat(undefined, {
|
|
41
|
+
year: "numeric",
|
|
42
|
+
month: "short",
|
|
43
|
+
day: "2-digit",
|
|
44
|
+
hour: "numeric",
|
|
45
|
+
minute: "2-digit",
|
|
46
|
+
}).format(date);
|
|
47
|
+
|
|
48
|
+
const tz = resolveTimeZoneLabel(date);
|
|
49
|
+
return `${base} ${tz}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A shorter local datetime intended for CLI metadata.
|
|
53
|
+
// Omits the year when the timestamp is within the current year.
|
|
54
|
+
export function formatLocalDateTimeShort(date, now = new Date()) {
|
|
55
|
+
const includeYear = date.getFullYear() !== now.getFullYear();
|
|
56
|
+
|
|
57
|
+
const opts = {
|
|
58
|
+
month: "short",
|
|
59
|
+
day: "numeric",
|
|
60
|
+
hour: "numeric",
|
|
61
|
+
minute: "2-digit",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if (includeYear) opts.year = "numeric";
|
|
65
|
+
|
|
66
|
+
const base = new Intl.DateTimeFormat(undefined, opts).format(date);
|
|
67
|
+
const tz = resolveTimeZoneLabel(date);
|
|
68
|
+
return `${base} ${tz}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function formatRelativeTime(fromMs, toMs = Date.now()) {
|
|
72
|
+
const delta = Math.max(0, toMs - fromMs);
|
|
73
|
+
|
|
74
|
+
if (delta < 5_000) return "just now";
|
|
75
|
+
|
|
76
|
+
const sec = Math.floor(delta / 1000);
|
|
77
|
+
if (sec < 60) return `${sec}s ago`;
|
|
78
|
+
|
|
79
|
+
const min = Math.floor(sec / 60);
|
|
80
|
+
if (min < 60) return `${min}m ago`;
|
|
81
|
+
|
|
82
|
+
const hr = Math.floor(min / 60);
|
|
83
|
+
if (hr < 24) return `${hr}h ago`;
|
|
84
|
+
|
|
85
|
+
const day = Math.floor(hr / 24);
|
|
86
|
+
if (day < 30) return `${day}d ago`;
|
|
87
|
+
|
|
88
|
+
const mo = Math.floor(day / 30);
|
|
89
|
+
if (mo < 12) return `${mo}mo ago`;
|
|
90
|
+
|
|
91
|
+
const yr = Math.floor(mo / 12);
|
|
92
|
+
return `${yr}y ago`;
|
|
93
|
+
}
|