Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

### Added — `search` and `open` convenience commands

- **`deepdive search "<query>"`** — run just the configured search adapter and print the raw candidate list (no LLM, no fetch, no browser). Honors `--search` and `--json`; `--results-per-query` sets the count (default 10). A cheap way to preview a backend or debug an adapter.
- **`deepdive open <id>`** — render a saved session to a self-contained HTML file (temp dir, or `--out=<path>`) and open it in the default browser. The file path is always printed, so it works on a headless box. Cross-platform opener (`open`/`xdg-open`/`start`) selected by a pure, tested `browserOpenCommand`; the target is passed as a single argv entry (no shell).

### Added — three more keyless research adapters

- **`--search=hackernews`** (alias `hn`, `src/search/hackernews.ts`) — Algolia-hosted HN search, no key. Community discussion / release threads; Ask/Show HN posts fall back to the HN thread URL. Snippet shows points/comments.
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,13 @@ One adapter per backend. Default (DuckDuckGo) needs no key.

Adding a new adapter is ~30 lines: implement `SearchAdapter` in `src/search/*.ts`, register in `src/search.ts`. The full contract + a copy-paste scaffold live in [docs/search-adapter.md](docs/search-adapter.md).

To preview what a backend returns (no LLM, no fetch), use the `search` subcommand:

```bash
deepdive search "rust async runtime" --search=hackernews
deepdive search "nginx 502" --search=stackexchange --json
```

---

## `deepdive doctor`
Expand Down Expand Up @@ -525,6 +532,8 @@ deepdive export 2026-05-07 --format=md # re-render the origina

The markdown→HTML rendering is hand-rolled (`src/markdown.ts`) so the export adds **no runtime dependency** — same audit-it-in-an-afternoon guarantee as the rest of the tool. The HTML is produced from the saved session, so you can export a run you did weeks ago without re-spending a token.

To render *and* open it in one step, `deepdive open <id>` writes the HTML to a temp file (or `--out=<path>`) and launches your default browser — the file path is always printed, so it works on a headless box too.

---

## Diff two runs — research over time
Expand Down
109 changes: 108 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
// events go to stderr when --verbose is set or DEEPDIVE_VERBOSE=1.

import { writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { resolve, join } from "node:path";
import { tmpdir } from "node:os";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { resolveConfig, type CLIFlags } from "./config.js";
import { parseMaxCost, BudgetExceededError } from "./budget.js";
Expand Down Expand Up @@ -43,6 +45,7 @@ import { assessConfidence, formatConfidenceLine } from "./confidence.js";
import { loadConfigFile, fileConfigToEnv } from "./config-file.js";
import { resolveProfile } from "./profiles.js";
import { completionScript, type Shell } from "./completion.js";
import { browserOpenCommand } from "./open.js";
import {
diffSessions,
renderDiffText,
Expand Down Expand Up @@ -77,6 +80,10 @@ Usage:
deepdive sessions rm <id> [<id>...] Delete one or more saved sessions
deepdive sessions prune --older-than=30d Delete old sessions (and/or --keep=<n> newest;
--dry-run to preview)
deepdive search "<query>" Run just the search adapter, print raw results
(no LLM/fetch). Honors --search / --json.
deepdive open <id> Render a session to HTML and open it in the
browser (--out to keep the file)
deepdive completion <bash|zsh|fish> Print a shell completion script
deepdive --help Show this help

Expand Down Expand Up @@ -202,6 +209,8 @@ const SUBCOMMAND_VERBS = new Set([
"export",
"diff",
"completion",
"search",
"open",
]);

// Exported for unit tests.
Expand Down Expand Up @@ -616,6 +625,12 @@ async function main(argv: string[]): Promise<number> {
if (parsed.question === "diff") {
return await diffCommand(parsed);
}
if (parsed.question === "search") {
return await searchCommand(parsed);
}
if (parsed.question === "open") {
return await openCommand(parsed);
}
if (!parsed.question) {
process.stderr.write(`deepdive: missing question.\n\n${USAGE}`);
return 2;
Expand Down Expand Up @@ -667,6 +682,98 @@ function completionCommand(parsed: ParsedArgs): number {
return 0;
}

// `deepdive search "<query>" [--search=<adapter>] [--json]` — run just the
// search adapter and print the raw candidate list. No LLM, no fetch, no
// browser — a cheap way to preview what a backend returns or debug an adapter.
async function searchCommand(parsed: ParsedArgs): Promise<number> {
const config = resolveConfig(parsed.flags, process.env);
const query = parsed.extras[0];
if (!query) {
process.stderr.write(
`deepdive: search requires a query (e.g. deepdive search "rust async" --search=hackernews)\n`,
);
return 2;
}
let adapter;
try {
adapter = await resolveSearchAdapter(config.searchAdapter, process.env);
} catch (err) {
process.stderr.write(`deepdive: ${safeErrorMessage(err)}\n`);
return 1;
}
const ac = new AbortController();
const sigint = () => ac.abort();
process.on("SIGINT", sigint);
process.on("SIGTERM", sigint);
try {
const count = parsed.flags.resultsPerQuery ?? 10;
const results = await adapter.search(query, count, ac.signal);
if (config.jsonOutput) {
process.stdout.write(
JSON.stringify({ adapter: adapter.name, query, results }, null, 2) + "\n",
);
return 0;
}
if (results.length === 0) {
process.stdout.write(`(no results from ${adapter.name} for "${query}")\n`);
return 0;
}
const lines = results.map((r) => {
const snip = r.snippet
? `\n ${ellipsize(r.snippet.replace(/\s+/g, " "), 100)}`
: "";
return `${String(r.rank).padStart(2)}. ${r.title || r.url}\n ${r.url}${snip}`;
});
process.stdout.write(lines.join("\n") + "\n");
return 0;
} catch (err) {
process.stderr.write(`deepdive: ${safeErrorMessage(err)}\n`);
return 1;
} finally {
process.off("SIGINT", sigint);
process.off("SIGTERM", sigint);
}
}

// `deepdive open <id> [--out=path]` — render a saved session to a self-
// contained HTML file (temp dir, or --out) and open it in the default browser.
// The browser spawn is best-effort; the file path is always printed so a
// headless box can open it manually.
async function openCommand(parsed: ParsedArgs): Promise<number> {
const config = resolveConfig(parsed.flags, process.env);
const idArg = parsed.extras[0];
if (!idArg) {
process.stderr.write(
`deepdive: open requires a session id (try \`deepdive sessions ls\`)\n`,
);
return 2;
}
try {
const id = await resolveSessionId(idArg, { dir: config.sessions.dir });
const record = await loadSession(id, { dir: config.sessions.dir });
const file = parsed.outPath
? resolve(parsed.outPath)
: join(tmpdir(), `deepdive-${id}.html`);
writeFileSync(file, renderHtmlReport(record), "utf-8");
const { cmd, args } = browserOpenCommand(process.platform, file);
try {
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
// Opener missing (e.g. headless box with no xdg-open) is non-fatal — the
// path is printed for manual opening.
child.on("error", () => undefined);
child.unref();
} catch {
/* non-fatal */
}
process.stderr.write(`opened ${file}\n`);
process.stdout.write(file + "\n");
return 0;
} catch (err) {
process.stderr.write(`deepdive: ${safeErrorMessage(err)}\n`);
return 1;
}
}

// v0.12.0 — the shared research path used by both the default
// `deepdive "<question>"` invocation and `deepdive continue <id>`.
// Continue threads `preKept` (saved sources from the parent session)
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export {
} from "./config-file.js";
export { BUILTIN_PROFILES, resolveProfile, listProfiles } from "./profiles.js";
export { completionScript, type Shell } from "./completion.js";
export { browserOpenCommand, type OpenCommand } from "./open.js";
export { createCache, cacheKey, type PageCache, type CacheOptions } from "./cache.js";
export { runConcurrent } from "./concurrency.js";
export {
Expand Down
33 changes: 33 additions & 0 deletions src/open.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Tiny cross-platform "open this file in the default app" helper. The command
// selection is a pure function (unit-testable without spawning anything); the
// actual spawn lives in the CLI. Used by `deepdive open <id>` to pop the
// exported HTML report in the user's browser.

export interface OpenCommand {
cmd: string;
args: string[];
}

// Returns the command + args to open `target` with the OS default handler.
// Every platform uses a DIRECT executable (no shell): the caller passes these
// straight to spawn() with shell:false, so `target` is a single argv entry and
// can never be interpreted as a command — no shell-injection surface, even
// from a user-supplied --out path or session id.
//
// Windows deliberately avoids `cmd /c start` (which would route through a
// shell): `explorer <file>` opens a path with its registered default handler
// (a browser, for .html) as a plain exec.
export function browserOpenCommand(
platform: NodeJS.Platform,
target: string,
): OpenCommand {
switch (platform) {
case "win32":
return { cmd: "explorer.exe", args: [target] };
case "darwin":
return { cmd: "open", args: [target] };
default:
// Linux / BSD — xdg-open is the freedesktop standard.
return { cmd: "xdg-open", args: [target] };
}
}
32 changes: 32 additions & 0 deletions test/open.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { browserOpenCommand } from "../dist/open.js";

test("browserOpenCommand: macOS uses open", () => {
assert.deepEqual(browserOpenCommand("darwin", "/tmp/r.html"), {
cmd: "open",
args: ["/tmp/r.html"],
});
});

test("browserOpenCommand: Windows uses explorer directly (no shell)", () => {
const c = browserOpenCommand("win32", "C:\\Temp\\r with space.html");
assert.equal(c.cmd, "explorer.exe");
// Single argv entry, no cmd.exe — the path can't be interpreted as a command.
assert.deepEqual(c.args, ["C:\\Temp\\r with space.html"]);
});

test("browserOpenCommand: Linux/other uses xdg-open", () => {
assert.deepEqual(browserOpenCommand("linux", "/tmp/r.html"), {
cmd: "xdg-open",
args: ["/tmp/r.html"],
});
assert.equal(browserOpenCommand("freebsd", "/x").cmd, "xdg-open");
});

test("browserOpenCommand: the target is always the final arg (single argv, no shell)", () => {
for (const p of ["darwin", "win32", "linux"]) {
const c = browserOpenCommand(p, "TARGET");
assert.equal(c.args[c.args.length - 1], "TARGET");
}
});