@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/util/view.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
function shQuote(s) {
|
|
7
|
+
// Minimal POSIX shell quoting.
|
|
8
|
+
const str = String(s ?? "");
|
|
9
|
+
return `'${str.replace(/'/g, `'"'"'`)}'`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function ensureDir(dir) {
|
|
13
|
+
await fs.mkdir(dir, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function writeTextFile(filePath, text) {
|
|
17
|
+
const dir = path.dirname(filePath);
|
|
18
|
+
await ensureDir(dir);
|
|
19
|
+
await fs.writeFile(filePath, String(text ?? ""), "utf8");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function pageText(text, { pager } = {}) {
|
|
23
|
+
const t = String(text ?? "");
|
|
24
|
+
|
|
25
|
+
// If we're not in an interactive terminal, just print.
|
|
26
|
+
if (!process.stdout.isTTY) {
|
|
27
|
+
process.stdout.write(t);
|
|
28
|
+
if (!t.endsWith("\n")) process.stdout.write("\n");
|
|
29
|
+
return { ok: true, mode: "stdout" };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const cmd = pager || process.env.PAGER || "less -R";
|
|
33
|
+
|
|
34
|
+
const run = (command) =>
|
|
35
|
+
new Promise((resolve) => {
|
|
36
|
+
const child = spawn(command, {
|
|
37
|
+
shell: true,
|
|
38
|
+
stdio: ["pipe", "inherit", "inherit"],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
child.on("error", () => resolve(false));
|
|
42
|
+
child.on("close", () => resolve(true));
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
child.stdin.write(t);
|
|
46
|
+
if (!t.endsWith("\n")) child.stdin.write("\n");
|
|
47
|
+
child.stdin.end();
|
|
48
|
+
} catch {
|
|
49
|
+
resolve(false);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const ok = await run(cmd);
|
|
54
|
+
if (ok) return { ok: true, mode: "pager", pager: cmd };
|
|
55
|
+
|
|
56
|
+
// Fallback to `more` if `less` isn't available.
|
|
57
|
+
const okMore = await run("more");
|
|
58
|
+
if (okMore) return { ok: true, mode: "pager", pager: "more" };
|
|
59
|
+
|
|
60
|
+
// Final fallback: stdout.
|
|
61
|
+
process.stdout.write(t);
|
|
62
|
+
if (!t.endsWith("\n")) process.stdout.write("\n");
|
|
63
|
+
return { ok: false, mode: "stdout" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function editText(text, { editor, filePath, keepFile = false } = {}) {
|
|
67
|
+
const t = String(text ?? "");
|
|
68
|
+
|
|
69
|
+
// Non-interactive: can't open an editor.
|
|
70
|
+
if (!process.stdout.isTTY) {
|
|
71
|
+
process.stdout.write(t);
|
|
72
|
+
if (!t.endsWith("\n")) process.stdout.write("\n");
|
|
73
|
+
return { ok: true, mode: "stdout" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const editorCmd = editor || process.env.EDITOR || process.env.VISUAL || "nano";
|
|
77
|
+
|
|
78
|
+
let temp = false;
|
|
79
|
+
let fp = filePath;
|
|
80
|
+
if (!fp) {
|
|
81
|
+
temp = true;
|
|
82
|
+
fp = path.join(os.tmpdir(), `versus-prompt-${process.pid}-${Date.now()}.txt`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
await writeTextFile(fp, t);
|
|
86
|
+
|
|
87
|
+
const cmd = `${editorCmd} ${shQuote(fp)}`;
|
|
88
|
+
|
|
89
|
+
const ok = await new Promise((resolve) => {
|
|
90
|
+
const child = spawn(cmd, {
|
|
91
|
+
shell: true,
|
|
92
|
+
stdio: "inherit",
|
|
93
|
+
});
|
|
94
|
+
child.on("error", () => resolve(false));
|
|
95
|
+
child.on("close", () => resolve(true));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (temp && !keepFile) {
|
|
99
|
+
try {
|
|
100
|
+
await fs.rm(fp, { force: true });
|
|
101
|
+
} catch {
|
|
102
|
+
// ignore
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { ok, mode: "editor", file: fp, editor: editorCmd };
|
|
107
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { hasAnyFlag } from "../src/util/argv.js";
|
|
4
|
+
|
|
5
|
+
test("hasAnyFlag detects both --flag value and --flag=value", () => {
|
|
6
|
+
const argv = ["nano", "vim", "--backend=gemini", "--ttl-hours=24", "-d"];
|
|
7
|
+
|
|
8
|
+
assert.equal(hasAnyFlag(argv, ["--backend"]), true);
|
|
9
|
+
assert.equal(hasAnyFlag(argv, ["--ttl-hours"]), true);
|
|
10
|
+
assert.equal(hasAnyFlag(argv, ["-d", "--debug"]), true);
|
|
11
|
+
assert.equal(hasAnyFlag(argv, ["--no-cache"]), false);
|
|
12
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { renderMarkdownToTerminal } from "../src/util/markdown.js";
|
|
5
|
+
import { stripAnsi } from "../src/util/text.js";
|
|
6
|
+
|
|
7
|
+
test("renderMarkdownToTerminal renders headings without markdown markers", () => {
|
|
8
|
+
const out = renderMarkdownToTerminal("# Title");
|
|
9
|
+
assert.equal(stripAnsi(out).trim(), "Title");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("renderMarkdownToTerminal renders bold without ** markers", () => {
|
|
13
|
+
const out = renderMarkdownToTerminal("This is **bold**.");
|
|
14
|
+
const plain = stripAnsi(out);
|
|
15
|
+
assert.match(plain, /This is bold\./);
|
|
16
|
+
assert.equal(plain.includes("**"), false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("renderMarkdownToTerminal renders a simple markdown table", () => {
|
|
20
|
+
const md = [
|
|
21
|
+
"| Feature | nano | vim |",
|
|
22
|
+
"| --- | --- | --- |",
|
|
23
|
+
"| Mode | modeless | modal |",
|
|
24
|
+
].join("\n");
|
|
25
|
+
|
|
26
|
+
const out = stripAnsi(renderMarkdownToTerminal(md));
|
|
27
|
+
// We don't assert exact layout, just that it becomes an ASCII table.
|
|
28
|
+
assert.match(out, /\+[-+]+\+/);
|
|
29
|
+
assert.match(out, /Feature/);
|
|
30
|
+
assert.match(out, /nano/);
|
|
31
|
+
assert.match(out, /vim/);
|
|
32
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { buildPrompt } from "../src/prompt.js";
|
|
4
|
+
|
|
5
|
+
test("buildPrompt includes both labels and docs blocks", () => {
|
|
6
|
+
const p = buildPrompt({
|
|
7
|
+
left: "curl",
|
|
8
|
+
right: "wget",
|
|
9
|
+
leftDocs: "LEFTDOC",
|
|
10
|
+
rightDocs: "RIGHTDOC",
|
|
11
|
+
level: "beginner",
|
|
12
|
+
mode: "summary",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
assert.match(p, /Compare "curl" and "wget"/);
|
|
16
|
+
assert.match(p, /--- DOCS: curl ---/);
|
|
17
|
+
assert.match(p, /LEFTDOC/);
|
|
18
|
+
assert.match(p, /--- DOCS: wget ---/);
|
|
19
|
+
assert.match(p, /RIGHTDOC/);
|
|
20
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { validateTargetForExec } from "../src/util/sanitize.js";
|
|
4
|
+
|
|
5
|
+
test("validateTargetForExec accepts common commands", () => {
|
|
6
|
+
assert.equal(validateTargetForExec("curl").ok, true);
|
|
7
|
+
assert.equal(validateTargetForExec("git fetch").ok, true);
|
|
8
|
+
assert.equal(validateTargetForExec("docker compose up").ok, true);
|
|
9
|
+
assert.equal(validateTargetForExec("python3.12").ok, true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("validateTargetForExec rejects shell injection chars", () => {
|
|
13
|
+
assert.equal(validateTargetForExec("ls; rm -rf /").ok, false);
|
|
14
|
+
assert.equal(validateTargetForExec("$(whoami)").ok, false);
|
|
15
|
+
assert.equal(validateTargetForExec("cat /etc/passwd").ok, false); // contains '/'
|
|
16
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { truncateAtWordBoundary } from "../src/util/text.js";
|
|
4
|
+
|
|
5
|
+
test("truncateAtWordBoundary does not chop a normal word in half", () => {
|
|
6
|
+
const text = "hello world this is a test";
|
|
7
|
+
const out = truncateAtWordBoundary(text, 10, { suffix: "" });
|
|
8
|
+
// 10 chars would cut "world" in the middle; word-boundary cut should end at "hello".
|
|
9
|
+
assert.equal(out, "hello");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("truncateAtWordBoundary falls back to hard cut when no whitespace is available", () => {
|
|
13
|
+
const text = "supercalifragilisticexpialidocious";
|
|
14
|
+
const out = truncateAtWordBoundary(text, 5, { suffix: "" });
|
|
15
|
+
assert.equal(out, "super");
|
|
16
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { formatRelativeTime } from "../src/util/time.js";
|
|
5
|
+
|
|
6
|
+
test("formatRelativeTime formats minutes and hours with rounding down", () => {
|
|
7
|
+
const now = 1_000_000_000;
|
|
8
|
+
|
|
9
|
+
assert.equal(formatRelativeTime(now - 3 * 60 * 1000, now), "3m ago");
|
|
10
|
+
assert.equal(formatRelativeTime(now - 59 * 60 * 1000, now), "59m ago");
|
|
11
|
+
assert.equal(formatRelativeTime(now - 2 * 60 * 60 * 1000, now), "2h ago");
|
|
12
|
+
});
|