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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 0.23.1 - Unreleased

- CLI slides: preserve URLs and media inputs following bare `--slides`, reject malformed per-run slide options, and return HTTP 400 for invalid daemon requests while preserving stored-config tolerance (#488, thanks @vincent-peng).
- CLI completions: offer every shipped interface locale across commands, keep locale values separate from daemon subcommands, and restore missing existing flag suggestions and documentation (#486, thanks @vincent-peng).

## 0.23.0 - 2026-09-21
Expand Down
4 changes: 2 additions & 2 deletions docs/commands/summarize.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,9 @@ If `[input]` is omitted, summarize prints concise help and exits.

### Slides

`--slides [value]` / `--no-slides`
`--slides [on|off]` / `--no-slides`
: Extract slides from a YouTube URL, direct video URL, or local video file and render inline alongside the summary. Combine with `--extract` to interleave slides in the full transcript. See [Slides mode](../slides.md).
: Use `--no-slides` to disable slide extraction enabled in the config file for one run.
: The optional value accepts boolean words (`on`/`off`, `true`/`false`, `yes`/`no`, `1`/`0`); anything else is rejected. Use `--no-slides` to disable slide extraction enabled in the config file for one run.

`--slides-ocr` / `--no-slides-ocr`
: Run OCR on extracted slides. Requires `tesseract`.
Expand Down
25 changes: 17 additions & 8 deletions src/daemon/server-summarize-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,9 @@ function resolveRequestSlidesSettings({
explicitEnvKey?: string,
) => string | null;
}): SlideSettings | null {
const slidesValue = request.slides;
const tesseractAvailable = resolveToolPath("tesseract", env, "TESSERACT_PATH") !== null;
const slidesOcrValue = tesseractAvailable ? request.slidesOcr : false;
return resolveSlideSettings({
slides: slidesValue,
slidesOcr: slidesOcrValue,
const input = {
slides: request.slides,
slidesOcr: request.slidesOcr,
// Daemon/API callers may be browser-extension or localhost clients that
// only need to request extraction, not select host filesystem paths. Keep
// slide artifacts under the per-user Summarize directory so an authenticated
Expand All @@ -51,7 +48,11 @@ function resolveRequestSlidesSettings({
slidesMax: request.slidesMax,
slidesMinDuration: request.slidesMinDuration,
cwd: resolveHomeDir(env),
});
};
const settings = resolveSlideSettings(input);
if (!settings?.ocr || resolveToolPath("tesseract", env, "TESSERACT_PATH") !== null)
return settings;
return resolveSlideSettings({ ...input, slidesOcr: false });
}

export type ParsedSummarizeRequest = {
Expand Down Expand Up @@ -131,7 +132,6 @@ export async function parseSummarizeRequest({
const format: "text" | "markdown" =
formatRaw === "markdown" || formatRaw === "md" ? "markdown" : "text";
const overrides = resolveRunOverrides(obj);
const slidesSettings = resolveRequestSlidesSettings({ env, request: obj, resolveToolPath });
const diagnostics = parseDiagnostics(obj.diagnostics);
const hasText = Boolean(textContent.trim());

Expand All @@ -150,6 +150,15 @@ export async function parseSummarizeRequest({
return null;
}

let slidesSettings: SlideSettings | null;
try {
slidesSettings = resolveRequestSlidesSettings({ env, request: obj, resolveToolPath });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
json(res, 400, { ok: false, error: message }, cors);
return null;
}

return {
pageUrl,
title,
Expand Down
2 changes: 1 addition & 1 deletion src/run/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export function buildProgram(locale: CliLocale = "en") {
.default("auto"),
)
.option(
"--slides [value]",
"--slides [on|off]",
t(
"extract.slides.for.youtube.direct.video.urls.or.local.video.files.and.render.them.inline.inside.the.summary.narrative.when.supported.combine.with.extract.to.interleave.slides.in.the.full.transcript",
),
Expand Down
10 changes: 5 additions & 5 deletions src/run/runner-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function prepareRunEnvironment(
argv: string[],
inputEnv: Record<string, string | undefined>,
) {
const normalizedArgv = normalizeDiarizeArgv(argv);
const normalizedArgv = normalizeMediaInputArgv(argv);
const preSeparatorArgv = argvBeforeSeparator(normalizedArgv);
const noColorFlag = preSeparatorArgv.includes("--no-color");
let envForRun: Record<string, string | undefined> = noColorFlag
Expand Down Expand Up @@ -52,15 +52,15 @@ export function stripCliLocaleArgs(argv: readonly string[], locale: CliLocale =
return stripped;
}

export function normalizeDiarizeArgv(argv: string[]): string[] {
export function normalizeMediaInputArgv(argv: string[]): string[] {
const separatorIndex = argv.indexOf("--");
return argv.map((arg, index) => {
if (separatorIndex !== -1 && index > separatorIndex) return arg;
if (arg !== "--diarize") return arg;
const value = arg === "--diarize" ? "auto" : arg === "--slides" ? "true" : null;
if (value === null) return arg;
const next = argv[index + 1];
if (!next || next.startsWith("-")) return arg;
if (["auto", "elevenlabs", "openai"].includes(next.toLowerCase())) return arg;
return /^[a-z][a-z\d+.-]*:\/\//i.test(next) || isDirectMediaUrl(next) ? "--diarize=auto" : arg;
return /^[a-z][a-z\d+.-]*:\/\//i.test(next) || isDirectMediaUrl(next) ? `${arg}=${value}` : arg;
});
}

Expand Down
37 changes: 22 additions & 15 deletions src/slides/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,24 @@ const DEFAULT_MIN_DURATION_SECONDS = 2;
const DECIMAL_INT_PATTERN = /^\d+$/;
const DECIMAL_NUMBER_PATTERN = /^\d+(?:\.\d+)?$/;

const parseBoolean = (raw: unknown): boolean | null => {
const parseBoolean = (raw: unknown, label: string): boolean | null => {
if (raw == null) return null;
if (typeof raw === "boolean") return raw;
if (typeof raw !== "string") return null;
const normalized = raw.trim().toLowerCase();
if (!normalized) return null;
if (["1", "true", "yes", "on"].includes(normalized)) return true;
if (["0", "false", "no", "off"].includes(normalized)) return false;
return null;
if (typeof raw === "string") {
const normalized = raw.trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) return true;
if (["0", "false", "no", "off"].includes(normalized)) return false;
}
throw new Error(`Unsupported ${label}: ${String(raw)}`);
};

const parsePositiveInt = (raw: unknown, label: string, min = 1): number | null => {
if (raw == null) return null;
const value = typeof raw === "string" ? raw.trim() : raw;
if (typeof value === "string" && !DECIMAL_INT_PATTERN.test(value)) {
if (
typeof value !== "number" &&
(typeof value !== "string" || !DECIMAL_INT_PATTERN.test(value))
) {
throw new Error(`Unsupported ${label}: ${String(raw)}`);
}
const numeric = typeof value === "number" ? value : Number(value);
Expand All @@ -61,7 +65,10 @@ const parseNumberInRange = (
): number | null => {
if (raw == null) return null;
const value = typeof raw === "string" ? raw.trim() : raw;
if (typeof value === "string" && !DECIMAL_NUMBER_PATTERN.test(value)) {
if (
typeof value !== "number" &&
(typeof value !== "string" || !DECIMAL_NUMBER_PATTERN.test(value))
) {
throw new Error(`Unsupported ${label}: ${String(raw)}`);
}
const numeric = typeof value === "number" ? value : Number(value);
Expand All @@ -75,13 +82,9 @@ const parseNumberInRange = (
};

export function resolveSlideSettings(input: SlideSettingsInput): SlideSettings | null {
const slidesFlag = parseBoolean(input.slides);
const ocrFlag = parseBoolean(input.slidesOcr);
const slidesFlag = parseBoolean(input.slides, "--slides");
const ocrFlag = parseBoolean(input.slidesOcr, "--slides-ocr");
const enabled = Boolean((slidesFlag ?? false) || (ocrFlag ?? false));
if (!enabled) return null;

const dirRaw = typeof input.slidesDir === "string" ? input.slidesDir.trim() : DEFAULT_OUTPUT_DIR;
const outputDir = path.resolve(input.cwd, dirRaw || DEFAULT_OUTPUT_DIR);

const sceneThreshold =
parseNumberInRange(input.slidesSceneThreshold, "--slides-scene-threshold", {
Expand All @@ -94,6 +97,10 @@ export function resolveSlideSettings(input: SlideSettingsInput): SlideSettings |
min: 0,
max: 86_400,
}) ?? DEFAULT_MIN_DURATION_SECONDS;
if (!enabled) return null;

const dirRaw = typeof input.slidesDir === "string" ? input.slidesDir.trim() : DEFAULT_OUTPUT_DIR;
const outputDir = path.resolve(input.cwd, dirRaw || DEFAULT_OUTPUT_DIR);
return {
enabled,
ocr: Boolean(ocrFlag ?? false),
Expand Down
78 changes: 73 additions & 5 deletions tests/cli.flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import {
} from "../src/flags.js";
import { buildProgram } from "../src/run/help.js";
import { resolveRunnerFlags } from "../src/run/runner-flags.js";
import { normalizeDiarizeArgv, prepareRunEnvironment } from "../src/run/runner-setup.js";
import { normalizeMediaInputArgv, prepareRunEnvironment } from "../src/run/runner-setup.js";
import { resolveSlideSettings } from "../src/slides/index.js";

describe("cli flag parsing", () => {
it("defaults summary length to long", () => {
Expand All @@ -36,7 +37,7 @@ describe("cli flag parsing", () => {

it("treats a URL after bare --diarize as the positional input", () => {
const url = "https://www.youtube.com/watch?v=abcdefghijk";
const argv = normalizeDiarizeArgv(["--diarize", url]);
const argv = normalizeMediaInputArgv(["--diarize", url]);
const program = buildProgram();
program.parse(argv, { from: "user" });

Expand All @@ -48,7 +49,7 @@ describe("cli flag parsing", () => {
it.each(["recording.mp3", "/tmp/interview.mp4"])(
"treats %s after bare --diarize as the positional input",
(input) => {
const argv = normalizeDiarizeArgv(["--diarize", input]);
const argv = normalizeMediaInputArgv(["--diarize", input]);
const program = buildProgram();
program.parse(argv, { from: "user" });

Expand All @@ -60,15 +61,82 @@ describe("cli flag parsing", () => {

it("keeps explicit diarization providers intact", () => {
const url = "https://www.youtube.com/watch?v=abcdefghijk";
expect(normalizeDiarizeArgv(["--diarize", "openai", url])).toEqual([
expect(normalizeMediaInputArgv(["--diarize", "openai", url])).toEqual([
"--diarize",
"openai",
url,
]);
});

it("keeps bare --diarize unchanged when no positional input follows", () => {
expect(normalizeDiarizeArgv(["--diarize"])).toEqual(["--diarize"]);
expect(normalizeMediaInputArgv(["--diarize"])).toEqual(["--diarize"]);
});

it("treats a URL after bare --slides as the positional input", () => {
const url = "https://www.youtube.com/watch?v=abcdefghijk";
const { normalizedArgv: argv } = prepareRunEnvironment(["--slides", url], {});
const program = buildProgram();
program.parse(argv, { from: "user" });

expect(argv).toEqual(["--slides=true", url]);
expect(program.opts().slides).toBe("true");
expect(program.args).toEqual([url]);
});

it.each(["lecture.mp4", "/tmp/talk.mkv"])(
"treats %s after bare --slides as the positional input",
(input) => {
const argv = normalizeMediaInputArgv(["--slides", input]);
const program = buildProgram();
program.parse(argv, { from: "user" });

expect(argv).toEqual(["--slides=true", input]);
expect(program.opts().slides).toBe("true");
expect(program.args).toEqual([input]);
},
);

it("keeps explicit boolean values after --slides intact", () => {
const url = "https://example.com";
expect(normalizeMediaInputArgv(["--slides", "off", url])).toEqual(["--slides", "off", url]);
const program = buildProgram();
program.parse(["--slides", "off", url], { from: "user" });
expect(program.opts().slides).toBe("off");
expect(program.args).toEqual([url]);
});

it("keeps bare --slides unchanged when no positional input follows", () => {
expect(normalizeMediaInputArgv(["--slides"])).toEqual(["--slides"]);
expect(normalizeMediaInputArgv(["--slides", "--json"])).toEqual(["--slides", "--json"]);
});

it("keeps bare --slides before the -- separator unchanged", () => {
expect(normalizeMediaInputArgv(["--slides", "--", "clip.mp4"])).toEqual([
"--slides",
"--",
"clip.mp4",
]);
expect(normalizeMediaInputArgv(["--", "--slides", "clip.mp4"])).toEqual([
"--",
"--slides",
"clip.mp4",
]);
});

it("rejects unsupported --slides and --slides-ocr values", () => {
expect(() => resolveSlideSettings({ slides: "bogus", cwd: "/" })).toThrow(
/Unsupported --slides: bogus/,
);
expect(() => resolveSlideSettings({ slides: 1, cwd: "/" })).toThrow(/Unsupported --slides: 1/);
expect(() => resolveSlideSettings({ slides: true, slidesOcr: "bogus", cwd: "/" })).toThrow(
/Unsupported --slides-ocr: bogus/,
);
});

it("accepts boolean words for --slides and rejects explicit empty values", () => {
expect(resolveSlideSettings({ slides: "yes", cwd: "/" })?.enabled).toBe(true);
expect(resolveSlideSettings({ slides: "off", cwd: "/" })).toBeNull();
expect(() => resolveSlideSettings({ slides: "", cwd: "/" })).toThrow(/Unsupported --slides:/);
});

it("parses speaker identity profiles and repeatable timestamp anchors", () => {
Expand Down
40 changes: 40 additions & 0 deletions tests/daemon.request-slides-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,44 @@ describe("parseSummarizeRequest slides settings", () => {
sceneThreshold: 0.5,
});
});

it.each([
[{ slides: "" }, /Unsupported --slides:/],
[{ slidesOcr: " " }, /Unsupported --slides-ocr:/],
[{ slidesMax: "bogus" }, /Unsupported --slides-max: bogus/],
[{ slides: false, slidesSceneThreshold: 2 }, /Unsupported --slides-scene-threshold: 2/],
[{ slidesMax: true }, /Unsupported --slides-max: true/],
[{ slidesSceneThreshold: [0.5] }, /Unsupported --slides-scene-threshold: 0.5/],
[{ slidesMinDuration: false }, /Unsupported --slides-min-duration: false/],
[{ slides: "bogus" }, /Unsupported --slides: bogus/],
[{ slides: 1 }, /Unsupported --slides: 1/],
[{ slidesOcr: "bogus" }, /Unsupported --slides-ocr: bogus/],
[{ slides: true, slidesMax: "bogus" }, /Unsupported --slides-max: bogus/],
[{ slides: true, slidesSceneThreshold: 2 }, /Unsupported --slides-scene-threshold: 2/],
[{ slides: true, slidesMinDuration: -1 }, /Unsupported --slides-min-duration: -1/],
])("rejects invalid slide option %o with a 400 response", async (fields, errorPattern) => {
for (const toolsAvailable of [true, false]) {
const writeHead = vi.fn();
const end = vi.fn();
const res = { writeHead, end } as unknown as http.ServerResponse;

const parsed = await parseSummarizeRequest({
req: createJsonRequest({
url: "https://example.com/video.mp4",
mode: "url",
...fields,
}),
res,
cors: {},
env: { HOME: "/home/alice" },
resolveToolPath: toolsAvailable ? resolveToolPath : () => null,
});

expect(parsed).toBeNull();
expect(writeHead).toHaveBeenCalledWith(400, expect.any(Object));
const body = JSON.parse(String(end.mock.calls[0]?.[0])) as { ok?: boolean; error?: string };
expect(body.ok).toBe(false);
expect(body.error).toMatch(errorPattern);
}
});
});
Loading