@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/src/cli.js ADDED
@@ -0,0 +1,341 @@
1
+ import { Command } from "commander";
2
+ import { createRequire } from "module";
3
+
4
+ import { runComparison, buildComparisonPrompt } from "./engine.js";
5
+ import { runStatus } from "./status.js";
6
+ import { clearCache, getCacheInfo } from "./cache.js";
7
+ import { loadUserConfig } from "./config.js";
8
+ import { hasAnyFlag } from "./util/argv.js";
9
+ import { createSpinner } from "./util/spinner.js";
10
+ import { editText, pageText, writeTextFile } from "./util/view.js";
11
+ import { renderMarkdownToTerminal } from "./util/markdown.js";
12
+ import { formatLocalDateTime, formatLocalDateTimeShort, formatRelativeTime } from "./util/time.js";
13
+ import { pc, setColorMode } from "./util/style.js";
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const pkg = require("../package.json");
17
+
18
+ function toInt(value, fallback) {
19
+ const n = Number.parseInt(String(value), 10);
20
+ return Number.isFinite(n) ? n : fallback;
21
+ }
22
+
23
+ function normalizeFormat(value) {
24
+ const v = String(value ?? "").trim().toLowerCase();
25
+ if (!v) return "rendered";
26
+ if (v === "rendered" || v === "pretty" || v === "plain") return "rendered";
27
+ if (v === "markdown" || v === "md" || v === "raw") return "markdown";
28
+ if (v === "json") return "json";
29
+ return v;
30
+ }
31
+
32
+ function normalizeOptions(raw, config, argv) {
33
+ const defaults = {
34
+ backend: "auto",
35
+ model: undefined,
36
+ level: "intermediate",
37
+ mode: "summary",
38
+ format: "rendered",
39
+ cache: true,
40
+ ttlHours: 720,
41
+ maxDocChars: 6000,
42
+ includeDocs: true,
43
+ debug: false,
44
+ };
45
+
46
+ // Start with hard defaults, then config, then CLI overrides (only when explicitly provided)
47
+ const merged = { ...defaults, ...(config || {}) };
48
+
49
+ // Explicit CLI overrides (only if user actually typed the flag)
50
+ if (hasAnyFlag(argv, ["-b", "--backend"])) merged.backend = raw.backend;
51
+ if (hasAnyFlag(argv, ["-m", "--model"])) merged.model = raw.model;
52
+
53
+ if (hasAnyFlag(argv, ["--level"])) merged.level = raw.level;
54
+ if (hasAnyFlag(argv, ["--mode"])) merged.mode = raw.mode;
55
+ if (hasAnyFlag(argv, ["--format"])) merged.format = normalizeFormat(raw.format);
56
+ if (hasAnyFlag(argv, ["--raw"])) merged.format = "markdown";
57
+
58
+ if (hasAnyFlag(argv, ["--ttl-hours"])) merged.ttlHours = toInt(raw.ttlHours, merged.ttlHours);
59
+ if (hasAnyFlag(argv, ["--max-doc-chars"])) merged.maxDocChars = toInt(raw.maxDocChars, merged.maxDocChars);
60
+
61
+ if (hasAnyFlag(argv, ["--no-cache"])) merged.cache = false;
62
+ if (hasAnyFlag(argv, ["--no-docs"])) merged.includeDocs = false;
63
+
64
+ if (hasAnyFlag(argv, ["-d", "--debug"])) merged.debug = true;
65
+
66
+ // Normalize numeric types
67
+ merged.ttlHours = toInt(merged.ttlHours, defaults.ttlHours);
68
+ merged.maxDocChars = toInt(merged.maxDocChars, defaults.maxDocChars);
69
+
70
+ merged.format = normalizeFormat(merged.format);
71
+
72
+ return merged;
73
+ }
74
+
75
+ function printError(err) {
76
+ const msg = err?.message || String(err);
77
+ console.error(pc.red("Error:"), msg);
78
+ if (err?.hint) console.error(pc.dim(err.hint));
79
+ if (process.env.VERSUS_DEBUG_STACK === "1" && err?.stack) {
80
+ console.error(pc.dim(err.stack));
81
+ }
82
+ }
83
+
84
+ function printHeader(left, right) {
85
+ const title = pc.bold(pc.cyan(`${left} vs ${right}`));
86
+ console.log(title);
87
+ console.log(pc.dim("─".repeat(Math.min(60, Math.max(10, title.length)))));
88
+ }
89
+
90
+ function shouldShowSpinner(options) {
91
+ // Keep JSON output super clean.
92
+ if (options.format === "json") return false;
93
+
94
+ // Mock backend is basically instant; avoid flicker.
95
+ if (options.backend === "mock") return false;
96
+
97
+ return Boolean(process.stderr.isTTY);
98
+ }
99
+
100
+ function normalizeArgvForColor(args) {
101
+ // Commander does not know about `--no-color` when `--color <mode>` is used.
102
+ // We treat it as an alias for `--color=never`.
103
+ return args.map((a) => (a === "--no-color" ? "--color=never" : a));
104
+ }
105
+
106
+ function extractColorMode(args) {
107
+ // Last occurrence wins.
108
+ for (let i = args.length - 1; i >= 0; i--) {
109
+ const a = args[i];
110
+ if (a === "--color" && i + 1 < args.length) return args[i + 1];
111
+ if (a.startsWith("--color=")) return a.slice("--color=".length);
112
+ }
113
+ return "auto";
114
+ }
115
+
116
+ export async function main(argv) {
117
+ const rawArgv = normalizeArgvForColor(argv.slice(2));
118
+ setColorMode(extractColorMode(rawArgv));
119
+
120
+ const program = new Command();
121
+ const argvForCommander = [...argv.slice(0, 2), ...rawArgv];
122
+
123
+ program
124
+ .name("versus")
125
+ .description("Compare two Linux commands or concepts using an LLM, grounded in local docs.")
126
+ .version(pkg.version)
127
+ .showHelpAfterError()
128
+ .showSuggestionAfterError();
129
+
130
+ program.addHelpText(
131
+ "after",
132
+ `\nExamples:\n versus nano vim\n versus curl wget --backend gemini\n versus "git pull" "git fetch" --level beginner\n\nUseful commands:\n versus status # environment / backend checks (alias: doctor)\n versus cache --clear # clear cached responses\n versus prompt nano vim # view the full generated prompt in a pager\n\nOutput tips:\n By default, versus renders Markdown for readability (TTY only).\n Use --raw (or --format markdown) to print raw Markdown.\n Control ANSI styling with --color auto|always|never (or set NO_COLOR=1).\n Alias: --no-color is the same as --color=never.\n\nTip: flags can be passed as --backend gemini OR --backend=gemini.\n`
133
+ );
134
+
135
+ program
136
+ .command("status")
137
+ .alias("doctor")
138
+ .description("Check environment (Node, man, cache path, and optional LLM backends).")
139
+ .option("--color <mode>", "ANSI styling: auto|always|never", "auto")
140
+ .option("--json", "output machine-readable JSON")
141
+ .action(async (opts) => {
142
+ try {
143
+ const report = await runStatus();
144
+ if (opts.json) {
145
+ console.log(JSON.stringify(report, null, 2));
146
+ return;
147
+ }
148
+ for (const line of report.human) console.log(line);
149
+ } catch (err) {
150
+ printError(err);
151
+ process.exitCode = 1;
152
+ }
153
+ });
154
+
155
+ program
156
+ .command("cache")
157
+ .description("Inspect or clear the local cache.")
158
+ .option("--color <mode>", "ANSI styling: auto|always|never", "auto")
159
+ .option("--clear", "delete all cache entries")
160
+ .option("--json", "output machine-readable JSON")
161
+ .action(async (opts) => {
162
+ try {
163
+ if (opts.clear) {
164
+ const cleared = await clearCache();
165
+ if (opts.json) {
166
+ console.log(JSON.stringify({ cleared }, null, 2));
167
+ } else {
168
+ console.log(pc.green(`Cleared ${cleared} cache entr${cleared === 1 ? "y" : "ies"}.`));
169
+ }
170
+ return;
171
+ }
172
+
173
+ const info = await getCacheInfo();
174
+ if (opts.json) {
175
+ console.log(JSON.stringify(info, null, 2));
176
+ } else {
177
+ console.log(pc.bold("Cache"));
178
+ console.log(`File: ${info.file}`);
179
+ console.log(`Entries: ${info.entries}`);
180
+ console.log(pc.dim("Tip: run `versus cache --clear` to delete all entries."));
181
+ }
182
+ } catch (err) {
183
+ printError(err);
184
+ process.exitCode = 1;
185
+ }
186
+ });
187
+
188
+ program
189
+ .command("prompt")
190
+ .description("Generate the full LLM prompt for a comparison (no backend call).")
191
+ .argument("<left>", "first command or concept")
192
+ .argument("<right>", "second command or concept")
193
+ .option("--color <mode>", "ANSI styling: auto|always|never", "auto")
194
+ .option("--level <level>", "beginner|intermediate|advanced", "intermediate")
195
+ .option("--mode <mode>", "summary|cheatsheet|table", "summary")
196
+ .option("--max-doc-chars <n>", "max characters of local docs to include per side", "6000")
197
+ .option("--no-docs", "do not read local docs")
198
+ .option("--stdout", "print prompt to stdout instead of opening a pager/editor")
199
+ .option("--editor", "open prompt in $EDITOR (fallback: nano)")
200
+ .option("--output <file>", "write prompt to a file")
201
+ .option("-d, --debug", "print debug metadata (doc sources, etc.)")
202
+ .action(async (left, right, opts) => {
203
+ const spinner = createSpinner({ text: "Building prompt" });
204
+ try {
205
+ const config = await loadUserConfig();
206
+ const options = normalizeOptions(opts, config, rawArgv);
207
+
208
+ const { prompt, leftInfo, rightInfo } = await buildComparisonPrompt(left, right, options);
209
+
210
+ spinner.stop();
211
+
212
+ if (opts.output) {
213
+ await writeTextFile(opts.output, prompt);
214
+ }
215
+
216
+ if (opts.stdout) {
217
+ process.stdout.write(prompt);
218
+ if (!prompt.endsWith("\n")) process.stdout.write("\n");
219
+ } else if (opts.editor) {
220
+ await editText(prompt, {
221
+ filePath: opts.output,
222
+ keepFile: Boolean(opts.output),
223
+ });
224
+ } else {
225
+ await pageText(prompt);
226
+ }
227
+
228
+ if (options.debug) {
229
+ const meta = {
230
+ sources: { left: leftInfo.sources, right: rightInfo.sources },
231
+ skipped: { left: leftInfo.skipped, right: rightInfo.skipped },
232
+ };
233
+ console.error(pc.dim("\nDebug"));
234
+ console.error(pc.dim(JSON.stringify(meta, null, 2)));
235
+ }
236
+ } catch (err) {
237
+ spinner.stop();
238
+ printError(err);
239
+ process.exitCode = 1;
240
+ }
241
+ });
242
+
243
+ program
244
+ .argument("<left>", "first command or concept")
245
+ .argument("<right>", "second command or concept")
246
+ .option("--color <mode>", "ANSI styling: auto|always|never", "auto")
247
+ .option("-b, --backend <backend>", "auto|openai|gemini|ollama|mock", "auto")
248
+ .option("-m, --model <model>", "model name (provider-specific)")
249
+ .option("--level <level>", "beginner|intermediate|advanced", "intermediate")
250
+ .option("--mode <mode>", "summary|cheatsheet|table", "summary")
251
+ .option("--format <format>", "rendered|markdown|json", "rendered")
252
+ .option("--raw", "output raw Markdown (disable terminal rendering)")
253
+ .option("--no-cache", "disable cache")
254
+ .option("--ttl-hours <n>", "cache TTL in hours (default: 720 = 30 days)")
255
+ .option("--max-doc-chars <n>", "max characters of local docs to include per side")
256
+ .option("--no-docs", "do not read local docs (LLM general knowledge only)")
257
+ .option("-d, --debug", "print debug metadata (timings, doc sources, cache)")
258
+ .action(async (left, right, opts) => {
259
+ let spinner = null;
260
+ try {
261
+ const config = await loadUserConfig();
262
+ const options = normalizeOptions(opts, config, rawArgv);
263
+
264
+ if (shouldShowSpinner(options)) spinner = createSpinner({ text: "Comparing" });
265
+
266
+ const result = await runComparison(left, right, options);
267
+
268
+ spinner?.stop();
269
+
270
+ if (options.format === "json") {
271
+ console.log(JSON.stringify(result, null, 2));
272
+ return;
273
+ }
274
+
275
+ printHeader(left, right);
276
+
277
+ // Small, helpful metadata (kept subtle).
278
+ const backendLine = `Backend: ${result.backend}${result.model ? ` (model: ${result.model})` : ""}`;
279
+ console.log(pc.dim(backendLine));
280
+
281
+ if (result.cached) {
282
+ if (result.createdAt) {
283
+ const dt = new Date(result.createdAt);
284
+ const nowMs = Date.now();
285
+ const ageMs = Math.max(0, nowMs - dt.getTime());
286
+ const rel = formatRelativeTime(dt.getTime(), nowMs);
287
+
288
+ // UX rule: show relative age when it's recent (actionable), otherwise show local absolute time.
289
+ // < 60m => "Cached (3m ago)"
290
+ // >= 60m => "Cached from Dec 28, 3:43 PM IST"
291
+ if (ageMs < 60 * 60 * 1000) {
292
+ console.log(pc.dim(`ℹ Cached (${rel}). Use --no-cache to refresh.`));
293
+ } else {
294
+ const localShort = formatLocalDateTimeShort(dt, new Date(nowMs));
295
+ console.log(pc.dim(`ℹ Cached from ${localShort}. Use --no-cache to refresh.`));
296
+ }
297
+
298
+ if (options.debug) {
299
+ const localFull = formatLocalDateTime(dt);
300
+ console.log(pc.dim(` (cache createdAt: local=${localFull}, relative=${rel}, utc/iso=${result.createdAt})`));
301
+ }
302
+ } else {
303
+ console.log(pc.dim("ℹ Cached response. Use --no-cache to refresh."));
304
+ }
305
+ }
306
+
307
+ console.log("");
308
+
309
+ const raw = result.text.trim();
310
+ let out = raw;
311
+
312
+ const shouldRender = options.format === "rendered" && Boolean(process.stdout.isTTY);
313
+
314
+ if (shouldRender) {
315
+ try {
316
+ out = renderMarkdownToTerminal(raw);
317
+ } catch (e) {
318
+ // Graceful fallback to raw markdown.
319
+ out = raw;
320
+ if (options.debug) {
321
+ console.error(pc.dim("(markdown render failed; falling back to raw)"));
322
+ }
323
+ }
324
+ }
325
+
326
+ console.log(out);
327
+ console.log("");
328
+
329
+ if (options.debug) {
330
+ console.log(pc.dim("Debug"));
331
+ console.log(pc.dim(JSON.stringify(result.meta, null, 2)));
332
+ }
333
+ } catch (err) {
334
+ spinner?.stop();
335
+ printError(err);
336
+ process.exitCode = 1;
337
+ }
338
+ });
339
+
340
+ await program.parseAsync(argvForCommander);
341
+ }
package/src/config.js ADDED
@@ -0,0 +1,30 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+
5
+ function configPaths() {
6
+ const home = os.homedir();
7
+ const xdg = process.env.XDG_CONFIG_HOME;
8
+ const base = xdg ? xdg : path.join(home, ".config");
9
+ return [
10
+ path.join(base, "versus", "config.json"),
11
+ path.join(home, ".versusrc.json"),
12
+ ];
13
+ }
14
+
15
+ export async function loadUserConfig() {
16
+ const paths = configPaths();
17
+ for (const p of paths) {
18
+ try {
19
+ const raw = await fs.readFile(p, "utf8");
20
+ const data = JSON.parse(raw);
21
+ if (data && typeof data === "object") return data;
22
+ } catch (err) {
23
+ if (err?.code === "ENOENT") continue;
24
+ const e = new Error(`Failed to read config file: ${p}`);
25
+ e.hint = "Fix the JSON syntax or delete the config file to use defaults.";
26
+ throw e;
27
+ }
28
+ }
29
+ return {};
30
+ }
package/src/engine.js ADDED
@@ -0,0 +1,143 @@
1
+ import { collectDocs } from "./introspect.js";
2
+ import { buildPrompt } from "./prompt.js";
3
+ import { cacheGet, cacheSet } from "./cache.js";
4
+ import { generateText } from "./backends/index.js";
5
+ import { sha256 } from "./util/text.js";
6
+ import { hrtimeMs, nowIso } from "./util/timing.js";
7
+
8
+ function computeCacheKey({ left, right, options, leftDocs, rightDocs }) {
9
+ const payload = {
10
+ v: 1, // bump to invalidate cache when prompt logic changes
11
+ left,
12
+ right,
13
+ backend: options.backend || "auto",
14
+ model: options.model || null,
15
+ level: options.level || "intermediate",
16
+ mode: options.mode || "summary",
17
+ includeDocs: Boolean(options.includeDocs),
18
+ docsHash: sha256(`${leftDocs}\n---\n${rightDocs}`),
19
+ };
20
+ return sha256(JSON.stringify(payload));
21
+ }
22
+
23
+ export async function buildComparisonPrompt(left, right, options) {
24
+ const includeDocs = options.includeDocs !== false;
25
+
26
+ const [leftInfo, rightInfo] = includeDocs
27
+ ? await Promise.all([
28
+ collectDocs(left, { maxChars: options.maxDocChars, debug: options.debug }),
29
+ collectDocs(right, { maxChars: options.maxDocChars, debug: options.debug }),
30
+ ])
31
+ : [
32
+ { docs: "", sources: [], skipped: "docs disabled" },
33
+ { docs: "", sources: [], skipped: "docs disabled" },
34
+ ];
35
+
36
+ const leftDocs = leftInfo.docs || "";
37
+ const rightDocs = rightInfo.docs || "";
38
+
39
+ const prompt = buildPrompt({
40
+ left,
41
+ right,
42
+ leftDocs,
43
+ rightDocs,
44
+ level: options.level,
45
+ mode: options.mode,
46
+ });
47
+
48
+ return { prompt, leftInfo, rightInfo, leftDocs, rightDocs };
49
+ }
50
+
51
+ export async function runComparison(left, right, options) {
52
+ const t0 = hrtimeMs();
53
+
54
+ const { prompt, leftInfo, rightInfo, leftDocs, rightDocs } = await buildComparisonPrompt(
55
+ left,
56
+ right,
57
+ options
58
+ );
59
+
60
+ const key = computeCacheKey({ left, right, options, leftDocs, rightDocs });
61
+
62
+ const ttlHours = Number.isFinite(options.ttlHours) ? options.ttlHours : 720;
63
+ const ttlMs = Math.max(0, ttlHours) * 60 * 60 * 1000;
64
+ const now = Date.now();
65
+
66
+ if (options.cache !== false) {
67
+ const cached = await cacheGet(key);
68
+ if (cached?.text) {
69
+ const t1 = hrtimeMs();
70
+ return {
71
+ left,
72
+ right,
73
+ backend: cached.backendUsed || cached.backend || options.backend || "auto",
74
+ model: cached.modelUsed || cached.model || options.model || null,
75
+ cached: true,
76
+ createdAt: cached.createdAt,
77
+ text: cached.text,
78
+ meta: {
79
+ cacheKey: key,
80
+ cacheHit: true,
81
+ msTotal: t1 - t0,
82
+ sources: {
83
+ left: leftInfo.sources,
84
+ right: rightInfo.sources,
85
+ },
86
+ skipped: {
87
+ left: leftInfo.skipped,
88
+ right: rightInfo.skipped,
89
+ },
90
+ },
91
+ };
92
+ }
93
+ }
94
+
95
+ const tLLM0 = hrtimeMs();
96
+ const gen = await generateText({
97
+ backend: options.backend,
98
+ prompt,
99
+ model: options.model,
100
+ });
101
+ const tLLM1 = hrtimeMs();
102
+
103
+ const createdAt = nowIso();
104
+ const expiresAt = ttlMs > 0 ? new Date(now + ttlMs).toISOString() : null;
105
+
106
+ const entry = {
107
+ createdAt,
108
+ expiresAt,
109
+ text: gen.text,
110
+ backendUsed: gen.backendUsed,
111
+ modelUsed: gen.modelUsed,
112
+ };
113
+
114
+ if (options.cache !== false) {
115
+ await cacheSet(key, entry);
116
+ }
117
+
118
+ const t1 = hrtimeMs();
119
+
120
+ return {
121
+ left,
122
+ right,
123
+ backend: gen.backendUsed,
124
+ model: gen.modelUsed,
125
+ cached: false,
126
+ createdAt,
127
+ text: gen.text,
128
+ meta: {
129
+ cacheKey: key,
130
+ cacheHit: false,
131
+ msTotal: t1 - t0,
132
+ msLLM: tLLM1 - tLLM0,
133
+ sources: {
134
+ left: leftInfo.sources,
135
+ right: rightInfo.sources,
136
+ },
137
+ skipped: {
138
+ left: leftInfo.skipped,
139
+ right: rightInfo.skipped,
140
+ },
141
+ },
142
+ };
143
+ }
@@ -0,0 +1,165 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ import { validateTargetForExec } from "./util/sanitize.js";
4
+ import { normalizeText, truncate } from "./util/text.js";
5
+
6
+ const DEFAULT_TIMEOUT_MS = 1200;
7
+
8
+ function blockedBecausePrivilege(tokens) {
9
+ const first = tokens[0];
10
+ return first === "sudo" || first === "su" || first === "doas";
11
+ }
12
+
13
+ function runCommand(cmd, args, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
14
+ return new Promise((resolve) => {
15
+ const child = spawn(cmd, args, {
16
+ stdio: ["ignore", "pipe", "pipe"],
17
+ env: {
18
+ ...process.env,
19
+ // Make man output less "paged" and less fancy.
20
+ MANPAGER: "cat",
21
+ PAGER: "cat",
22
+ LESS: "FRX",
23
+ },
24
+ });
25
+
26
+ let stdout = "";
27
+ let stderr = "";
28
+ let done = false;
29
+
30
+ const killTimer =
31
+ timeoutMs && timeoutMs > 0
32
+ ? setTimeout(() => {
33
+ if (done) return;
34
+ done = true;
35
+ try {
36
+ child.kill("SIGKILL");
37
+ } catch {}
38
+ resolve({ ok: false, stdout, stderr, timedOut: true });
39
+ }, timeoutMs)
40
+ : null;
41
+
42
+ child.stdout.on("data", (d) => (stdout += d.toString("utf8")));
43
+ child.stderr.on("data", (d) => (stderr += d.toString("utf8")));
44
+
45
+ child.on("error", () => {
46
+ if (done) return;
47
+ done = true;
48
+ if (killTimer) clearTimeout(killTimer);
49
+ resolve({ ok: false, stdout: "", stderr: "", error: true });
50
+ });
51
+
52
+ child.on("close", (code) => {
53
+ if (done) return;
54
+ done = true;
55
+ if (killTimer) clearTimeout(killTimer);
56
+ resolve({ ok: code === 0, stdout, stderr, code });
57
+ });
58
+ });
59
+ }
60
+
61
+ async function tryAddSource(sources, kind, cmd, args, maxCharsPerSource) {
62
+ const res = await runCommand(cmd, args);
63
+ const cleaned = normalizeText(res.stdout);
64
+ if (!cleaned) return;
65
+ sources.push({
66
+ kind,
67
+ cmd,
68
+ args,
69
+ chars: cleaned.length,
70
+ snippet: cleaned.slice(0, 80),
71
+ });
72
+ return truncate(cleaned, maxCharsPerSource);
73
+ }
74
+
75
+ export async function collectDocs(target, { maxChars = 6000, debug = false } = {}) {
76
+ const validated = validateTargetForExec(target);
77
+ if (!validated.ok) {
78
+ return {
79
+ docs: "",
80
+ sources: [],
81
+ skipped: validated.reason,
82
+ };
83
+ }
84
+
85
+ const tokens = validated.tokens;
86
+
87
+ if (blockedBecausePrivilege(tokens)) {
88
+ return {
89
+ docs: "",
90
+ sources: [],
91
+ skipped: `Skipping local docs for safety (starts with '${tokens[0]}').`,
92
+ };
93
+ }
94
+
95
+ // Split budget between sources. We'll aim for ~3 sources max, so maxChars/3 each.
96
+ const maxPerSource = Math.max(800, Math.floor(maxChars / 3));
97
+
98
+ const sources = [];
99
+ const [cmd, ...rest] = tokens;
100
+
101
+ const parts = [];
102
+
103
+ // Always try man for the base command first.
104
+ const manBase = await tryAddSource(sources, "man", "man", ["-P", "cat", cmd], maxPerSource);
105
+ if (manBase) parts.push(`## man ${cmd}\n${manBase}`);
106
+
107
+ // If we have a subcommand, try a man page like git-pull.
108
+ if (rest.length >= 1) {
109
+ const sub = rest[0];
110
+ const manSub = await tryAddSource(
111
+ sources,
112
+ "man-subcommand",
113
+ "man",
114
+ ["-P", "cat", `${cmd}-${sub}`],
115
+ maxPerSource
116
+ );
117
+ if (manSub) parts.push(`## man ${cmd}-${sub}\n${manSub}`);
118
+ }
119
+
120
+ // Try --help. For multi-token, we do: <cmd> <rest...> --help
121
+ {
122
+ const helpArgs = rest.length ? [...rest, "--help"] : ["--help"];
123
+ const helpOut = await tryAddSource(sources, "--help", cmd, helpArgs, maxPerSource);
124
+ if (helpOut) parts.push(`## ${cmd} ${helpArgs.join(" ")}\n${helpOut}`);
125
+ }
126
+
127
+ // Try info for base command (info doesn't do subcommands well usually).
128
+ {
129
+ const infoOut = await tryAddSource(sources, "info", "info", [cmd], maxPerSource);
130
+ if (infoOut) parts.push(`## info ${cmd}\n${infoOut}`);
131
+ }
132
+
133
+ // Try bash builtin help (only makes sense for single tokens like 'cd', 'help', etc.)
134
+ if (rest.length === 0) {
135
+ const bashOut = await tryAddSource(
136
+ sources,
137
+ "bash-help",
138
+ "bash",
139
+ ["-lc", `help ${cmd}`],
140
+ maxPerSource
141
+ );
142
+ if (bashOut) parts.push(`## bash help ${cmd}\n${bashOut}`);
143
+ }
144
+
145
+ // Heuristic: git help <subcommand>
146
+ if (cmd === "git" && rest.length >= 1) {
147
+ const sub = rest[0];
148
+ const gitHelp = await tryAddSource(
149
+ sources,
150
+ "git-help",
151
+ "git",
152
+ ["help", sub],
153
+ maxPerSource
154
+ );
155
+ if (gitHelp) parts.push(`## git help ${sub}\n${gitHelp}`);
156
+ }
157
+
158
+ const docs = parts.join("\n\n").trim();
159
+
160
+ return {
161
+ docs,
162
+ sources: debug ? sources : sources.map(({ kind }) => ({ kind })),
163
+ skipped: docs ? null : "No local docs found via man/--help/info/bash help.",
164
+ };
165
+ }