New:Socket for Asana Is Now Available.Learn more
Get Started

@clipy/cli

Package Overview
Dependencies
Maintainers
1
Versions
21
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@clipy/cli - npm Package Compare versions

Comparing version
0.9.1
to
0.9.2
+38
dist/context/errors.js
export class ImportError extends Error {
code;
remediation;
/** What survived, when something did. Non-null makes this a partial failure. */
partial;
constructor(code, message, remediation, partial = null) {
super(message);
this.name = "ImportError";
this.code = code;
this.remediation = remediation;
this.partial = partial;
}
}
export function isImportError(e) {
return e instanceof Error && e.name === "ImportError";
}
/**
* The one JSON object a failed `--json` run prints. Exactly one object per run,
* success or failure, so an agent can always `JSON.parse` stdout.
*/
export function errorEnvelope(e) {
if (isImportError(e)) {
return {
ok: false,
code: e.code,
error: e.message,
remediation: e.remediation,
partial: e.partial,
};
}
return {
ok: false,
code: "unknown",
error: e instanceof Error ? e.message : String(e),
remediation: "Re-run the command; if it fails again, report this output as a bug.",
partial: null,
};
}
/**
* Which yt-dlp failures are worth trying again.
*
* YouTube hands out media URLs that expire, and it throttles bursts. Both show
* up as an HTTP 403 on a command that succeeds verbatim a few seconds later —
* so a single attempt turns a hiccup into "frames could not be extracted".
* Against that, a genuinely unavailable video must fail on the first try:
* retrying a private video three times just makes the user wait longer for the
* same answer.
*/
/**
* Failures no client change or retry can fix. Checked FIRST, ahead of the 403
* test, because YouTube sometimes wraps these in a 403 — and retrying a bot
* check or an age gate burns the user's IP reputation for nothing.
*/
const FATAL_MARKERS = [
/Sign in to confirm you.?re not a bot/i,
/Sign in to confirm your age/i,
/This video is unavailable/i,
/Video unavailable/i,
/Private video/i,
/members-only/i,
/age.?restricted/i,
];
const FORBIDDEN = /HTTP Error 403|\b403 Forbidden\b/i;
const TRANSIENT = [
/HTTP Error 5\d\d/i,
/HTTP Error 429/i,
/\btimed out\b/i,
/\btimeout\b/i,
/connection reset/i,
/ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENETUNREACH/,
/temporary failure/i,
/unable to connect/i,
/remote end closed connection/i,
];
/**
* Classifies yt-dlp's stderr. Only ever called on yt-dlp's OWN output — our
* local aborts (the size ceiling, our 20-minute cap) are fatal by construction
* and must not be routed through here, or a slow link would be retried for an
* hour.
*/
export function classifyFailure(stderr) {
if (FATAL_MARKERS.some((re) => re.test(stderr)))
return "fatal";
if (FORBIDDEN.test(stderr))
return "forbidden";
if (TRANSIENT.some((re) => re.test(stderr)))
return "transient";
return "fatal";
}
export function isRetriable(kind) {
return kind !== "fatal";
}
/**
* A stale binary and a transient 403 look nothing alike once you know where to
* look: a 403 fails mid-transfer with formats already resolved, while an
* outdated yt-dlp fails during EXTRACTION — it cannot solve the signature
* challenge or is being forced onto SABR. Only the second is fixed by updating,
* so only the second should suggest it.
*/
const STALE_BINARY_MARKERS = [
/unable to extract/i,
/Only images are available/i,
/Signature extraction failed/i,
/n challenge/i,
/nsig extraction failed/i,
/forcing SABR/i,
/Requested format is not available/i,
/no video formats found/i,
];
export function looksLikeStaleBinary(stderr) {
return STALE_BINARY_MARKERS.some((re) => re.test(stderr));
}
/** How a retry is announced, so every caller says it the same way. */
export function retryNotice(stderr, kind, attempt, total) {
const what = kind === "forbidden"
? "Download blocked (HTTP 403)"
: `Download failed (${firstUsefulLine(stderr)})`;
return `${what} — retrying (${attempt}/${total})…`;
}
function firstUsefulLine(stderr) {
const line = stderr.trim().split("\n").filter(Boolean).pop() ?? "unknown error";
return line.replace(/^ERROR:\s*/i, "").slice(0, 120);
}
export function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
/** Backoff between attempts 1→2 and 2→3. The observed recovery window for an
* expired media URL is "a minute later", so these are deliberately unhurried. */
export const RETRY_BACKOFF_MS = [3_000, 8_000];
export const MAX_ATTEMPTS = 3;
/**
* On every attempt. Letting yt-dlp retry a mid-transfer 403 in-process is far
* cheaper than re-running the binary, and pacing the extraction burst is what
* keeps a residential IP from reading as automation. Concurrency stays at 1 —
* we fetch one bounded rendition once, so there is nothing to win by raising it
* and a rate-limit to lose.
*/
export const BASELINE_DOWNLOAD_ARGS = [
"--retries", "5",
"--fragment-retries", "5",
"--extractor-retries", "3",
"--retry-sleep", "http:exp=1:30",
"--retry-sleep", "fragment:exp=1:30",
"--sleep-requests", "0.75",
"--concurrent-fragments", "1",
];
/**
* The client fallback, used only after the defaults have failed repeatedly.
*
* These three need no PO token and (for tv/android_vr) no JS runtime, unlike
* `android`, which is PO-token-gated and is NOT in yt-dlp's default set —
* falling back to it moves away from token-free clients rather than toward
* them. See docs/research/2026-07-29-ytdlp-403-fallbacks.md §2.
*/
export const TOKEN_FREE_CLIENT_ARGS = [
"--extractor-args",
"youtube:player_client=tv,android_vr,web_embedded",
];
/**
* Last resort: a pre-muxed progressive file is served as one conventional HTTP
* download, sidestepping both adaptive-fragment 403s and SABR. Format 18 is
* 360p — worse than our 720p target, and far better than no frames at all.
*/
export const PROGRESSIVE_FALLBACK = {
format: "b[height<=720]/18",
extraArgs: ["--extractor-args", "youtube:player_client=tv,android_vr"],
};
export const DEFAULT_FORMAT = "bv*[height<=720]/b[height<=720]";
/**
* YouTube URL → video id, tolerantly.
*
* The motivating failure: zsh users copy a URL and escape it, so the CLI is
* handed `https://www.youtube.com/watch\?v\=77FB-LS0Bjk` with literal
* backslashes. Passed through verbatim, yt-dlp does not error on that — it
* resolves it to a placeholder video ("Press Subscribe to continue", 0:00) and
* the user gets a bundle for a video they never asked for. So nothing raw ever
* reaches yt-dlp: we extract the id and rebuild the canonical URL ourselves.
*/
/** YouTube ids are exactly 11 chars of base64url. */
const VIDEO_ID = /^[A-Za-z0-9_-]{11}$/;
/** Shell escaping and stray whitespace are noise, never meaning, in a URL. */
function deEscape(raw) {
return raw.trim().replace(/\\/g, "").replace(/\s+/g, "");
}
const YOUTUBE_HOSTS = new Set([
"youtube.com",
"www.youtube.com",
"m.youtube.com",
"music.youtube.com",
"youtube-nocookie.com",
"www.youtube-nocookie.com",
"youtu.be",
"www.youtu.be",
]);
/** The path forms that carry the id as their first segment. */
const PATH_PREFIXES = ["shorts", "live", "embed", "v"];
export function isYoutubeHost(host) {
return YOUTUBE_HOSTS.has(host.toLowerCase());
}
/**
* Returns the 11-char video id, or null if this is not a recognisable
* single-video YouTube URL (a channel, a playlist-only link, or garbage).
*/
export function parseYoutubeId(raw) {
const cleaned = deEscape(raw);
let url = null;
try {
url = new URL(cleaned);
}
catch {
url = null;
}
if (url && isYoutubeHost(url.hostname)) {
const v = url.searchParams.get("v");
if (v && VIDEO_ID.test(v))
return v;
const segments = url.pathname.split("/").filter(Boolean);
if (url.hostname.toLowerCase().endsWith("youtu.be")) {
if (segments[0] && VIDEO_ID.test(segments[0]))
return segments[0];
}
if (segments.length >= 2 && PATH_PREFIXES.includes(segments[0].toLowerCase())) {
if (VIDEO_ID.test(segments[1]))
return segments[1];
}
}
// Last resort for input too mangled to parse as a URL at all (a lost scheme,
// a doubled query separator). Anchored on the YouTube-specific markers so a
// random 11-char word in an unrelated string can never match.
const loose = /(?:youtube(?:-nocookie)?\.com\/(?:watch\?(?:[^#]*&)?v=|shorts\/|live\/|embed\/|v\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/.exec(cleaned);
return loose ? loose[1] : null;
}
/** The one URL shape every yt-dlp call and every manifest uses. */
export function canonicalYoutubeUrl(videoId) {
return `https://www.youtube.com/watch?v=${videoId}`;
}
+39
-0

@@ -43,2 +43,11 @@ // GENERATED from lib/context-core — do not edit here

}
/**
* For text going INSIDE a code span, where mdInline is exactly wrong: it strips
* `_`, `*` and `[]`, which silently corrupts an error code or a shell command
* the reader is meant to copy. A code span only has to survive its own fence,
* so backticks and newlines are the only things that need removing.
*/
function mdCode(raw) {
return raw.replace(/[\r\n]+/g, ' ').replace(/`/g, '').trim();
}
export function buildManifest(input) {

@@ -63,2 +72,3 @@ return {

...(input.frames && input.frames.length ? { frames: input.frames } : {}),
...(input.completeness ? { completeness: input.completeness } : {}),
createdAt: input.createdAt ?? new Date().toISOString(),

@@ -77,2 +87,3 @@ };

auto_captions: 'provider auto-generated captions',
auto_captions_translated: 'provider auto-generated captions, machine-translated from another language',
user_file: 'a caption file supplied by the user',

@@ -88,2 +99,30 @@ local_stt: 'local speech-to-text on the user’s machine',

lines.push('');
// Directly under the title, before anything a reader might act on: a bundle
// that is missing evidence must say so where nobody can miss it, and a bundle
// that is transcript-only ON PURPOSE must say THAT — otherwise the two are
// indistinguishable on disk and an agent re-runs a finished import, or trusts
// an unfinished one.
const completeness = manifest.completeness;
if (completeness && completeness.status === 'incomplete') {
lines.push('## Incomplete');
lines.push('');
lines.push(`**This bundle is missing visual evidence.** ${completeness.missingFrames
? `${completeness.missingFrames} frame${completeness.missingFrames === 1 ? '' : 's'} the classifier asked for ${completeness.missingFrames === 1 ? 'was' : 'were'} not captured`
: 'Frames the classifier asked for were not captured'}${completeness.reasonCode ? ` (\`${mdCode(completeness.reasonCode)}\`)` : ''}.`);
lines.push('');
if (completeness.reason) {
lines.push(`- Why: ${mdInline(completeness.reason)}`);
}
lines.push('- Still complete and usable: the transcript, the metadata, and the server classification below. Read them normally.');
if (completeness.rerunCommand) {
lines.push(`- To complete this bundle, re-run exactly: \`${mdCode(completeness.rerunCommand)}\``);
lines.push(' Imports are idempotent — this fills in what is missing rather than creating a second document.');
}
lines.push('');
}
else if (manifest.serverClassification &&
!manifest.serverClassification.needsVisual) {
lines.push('> This bundle is transcript-only BY DESIGN: the classifier judged the words sufficient on their own, so no frames were planned. Nothing is missing — do not re-run this import to "complete" it.');
lines.push('');
}
lines.push('## Metadata');

@@ -90,0 +129,0 @@ lines.push('');

+194
-58

@@ -18,19 +18,17 @@ /**

import { buildManifest, buildNormalizedTranscript, classifyTranscript, parseSrt, parseVtt, parseYoutubeJson3, renderArecMarkdown, slugHash, } from "../context-core/index.js";
import { fetchCaptions, fetchVideoMeta, resolveYtDlp } from "./ytdlp.js";
import { describeCaptions, fetchCaptions, fetchVideoMeta, resolveYtDlp } from "./ytdlp.js";
import { canonicalYoutubeUrl, isYoutubeHost, parseYoutubeId } from "./youtubeUrl.js";
import { ImportError } from "./errors.js";
import { looksLikeStaleBinary } from "./retry.js";
import { probeVideo } from "./probe.js";
import { extractFrames } from "./frames.js";
import { borrowLocalVideo, borrowYoutubeVideo } from "./videoFetch.js";
const YOUTUBE_HOSTS = new Set([
"youtube.com",
"www.youtube.com",
"m.youtube.com",
"music.youtube.com",
"youtu.be",
"www.youtu.be",
]);
function classifyInput(raw) {
const trimmed = raw.trim();
// Shell-escaped input (`watch\?v\=…`) still has to be recognised as YouTube,
// so host detection runs on the de-escaped string.
const unescaped = trimmed.replace(/\\/g, "");
let parsed = null;
try {
parsed = new URL(trimmed);
parsed = new URL(unescaped);
}

@@ -41,6 +39,11 @@ catch {

if (parsed && (parsed.protocol === "http:" || parsed.protocol === "https:")) {
const host = parsed.hostname.toLowerCase();
if (YOUTUBE_HOSTS.has(host))
return { kind: "youtube", url: trimmed };
return { kind: "url", url: trimmed };
if (isYoutubeHost(parsed.hostname)) {
const videoId = parseYoutubeId(trimmed);
if (!videoId) {
throw new ImportError("invalid_url", `could not find a video id in "${raw}". Channel, playlist and search URLs are not supported.`, `Pass a single-video URL, e.g. clipy context import "https://www.youtube.com/watch?v=<id>" (youtu.be/<id> and /shorts/<id> also work).`);
}
// Everything downstream uses the canonical URL — never the raw input.
return { kind: "youtube", url: canonicalYoutubeUrl(videoId), videoId };
}
return { kind: "url", url: unescaped };
}

@@ -50,3 +53,3 @@ const path = resolve(trimmed);

return { kind: "local", path };
throw new Error(`could not read "${raw}" — pass a YouTube URL or the path to a local video file.`);
throw new ImportError("source_unreadable", `could not read "${raw}" — it is neither a YouTube URL nor a readable local file.`, `Check the path exists, or pass a YouTube URL: clipy context import "https://www.youtube.com/watch?v=<id>"`);
}

@@ -68,3 +71,3 @@ function sha256File(path) {

catch {
throw new Error(`--transcript ${path} is neither .vtt/.srt nor valid JSON. Supported: WebVTT, SubRip, or Clipy transcript JSON ({"segments":[{"startMs","endMs","text"}]}).`);
throw new ImportError("transcript_unreadable", `--transcript ${path} is neither .vtt/.srt nor valid JSON.`, `Supply WebVTT (.vtt), SubRip (.srt), or Clipy transcript JSON ({"segments":[{"startMs","endMs","text"}]}).`);
}

@@ -77,3 +80,3 @@ // json3 (YouTube) and Clipy transcript JSON both land here.

if (!Array.isArray(raw)) {
throw new Error(`--transcript ${path} has no usable segments.`);
throw new ImportError("transcript_unreadable", `--transcript ${path} has no usable segments.`, `Supply WebVTT (.vtt), SubRip (.srt), or Clipy transcript JSON ({"segments":[{"startMs","endMs","text"}]}).`);
}

@@ -91,6 +94,22 @@ const segments = [];

}
if (segments.length === 0)
throw new Error(`--transcript ${path} has no usable segments.`);
if (segments.length === 0) {
throw new ImportError("transcript_unreadable", `--transcript ${path} has no usable segments.`, `Supply WebVTT (.vtt), SubRip (.srt), or Clipy transcript JSON ({"segments":[{"startMs","endMs","text"}]}).`);
}
return segments;
}
/** Platform-correct, runnable verbatim — an agent should be able to paste it. */
const FFMPEG_INSTALL_COMMAND = process.platform === "darwin"
? "brew install ffmpeg"
: process.platform === "win32"
? "winget install Gyan.FFmpeg"
: "sudo apt install ffmpeg";
/** Resolves + parses `--transcript`, shared by every input kind. */
function loadUserTranscript(transcriptPath) {
const resolved = resolve(transcriptPath);
if (!existsSync(resolved)) {
throw new ImportError("transcript_unreadable", `--transcript ${resolved} does not exist.`, `Check the path, or drop --transcript to use the provider's captions.`);
}
notify(`Reading the transcript from ${basename(resolved)}…`);
return parseTranscriptFile(resolved);
}
function notify(message) {

@@ -128,18 +147,63 @@ process.stderr.write(`${message}\n`);

}
async function compileYoutube(url, opts) {
async function compileYoutube(url, videoId, opts) {
const bin = await resolveYtDlp(notify);
notify("Fetching video info…");
const meta = await fetchVideoMeta(bin, url);
// A zero duration is how YouTube's placeholder/consent stubs come back. They
// are not real videos, and compiling one produces an empty bundle attributed
// to a URL the user did ask for — worse than a refusal.
if (!meta.durationMs) {
throw new ImportError("invalid_url", `could not read this video: ${url} returned no duration. That usually means it is private, region-blocked, age-gated, members-only, or an unfinished live stream.`, `Open the URL in a browser to check it plays, or import a local copy: clipy context import ./<file> --transcript <file.vtt>`);
}
notify(`Found: "${meta.title}" (${fmtClock(meta.durationMs)})`);
const langPref = opts.language ?? meta.language ?? "en";
notify(`Downloading captions (${langPref})…`);
const captions = await fetchCaptions(bin, url, meta, langPref);
// providerId comes from the URL we parsed, not from yt-dlp's echo, so the
// manifest points at the video the user named even if metadata is partial.
const id = meta.id || videoId;
const source = {
kind: "youtube",
canonicalUrl: canonicalYoutubeUrl(id),
providerId: id,
};
// An explicit --transcript is the user overriding the provider, so YouTube's
// captions are never consulted — not even as a fallback.
if (opts.transcriptPath) {
const segments = loadUserTranscript(opts.transcriptPath);
notify(`Using your transcript file (${segments.length} segments) — skipping YouTube captions.`);
const transcript = buildNormalizedTranscript(segments, {
...(opts.language ? { language: opts.language } : {}),
source: "user_file",
durationMs: meta.durationMs || undefined,
});
return finish({
title: opts.title ?? meta.title,
source,
durationMs: meta.durationMs,
transcript,
fingerprint: `youtube:${id}`,
media: { kind: "youtube", url, ytDlpBin: bin, durationMs: meta.durationMs },
opts,
});
}
let downloadFailure = null;
const captions = await fetchCaptions(bin, url, meta, {
...(opts.language ? { language: opts.language } : {}),
notify,
onFailure: (reason) => {
downloadFailure = reason;
},
});
if (!captions) {
// A listed track that refused to download is a different problem with a
// different fix, so it must not be reported as an absent track.
if (downloadFailure) {
const stale = looksLikeStaleBinary(downloadFailure);
throw new ImportError("no_captions", `the captions are listed but could not be downloaded: ${downloadFailure}`, stale
? `This looks like an out-of-date yt-dlp (an extraction failure, not a block). Update it — the managed copy self-updates, or run: yt-dlp -U`
: `If this is HTTP 429, YouTube is rate-limiting this machine — wait a few minutes and re-run. Otherwise supply your own captions: clipy context import <url> --transcript <file.vtt>`);
}
const available = [...new Set([...meta.subtitleLangs, ...meta.autoCaptionLangs])];
if (available.length > 0) {
throw new Error(`this video has no captions in "${langPref}". Re-run with --language <code> (e.g. --language ${available.includes("en") ? "en" : available[0]}).`);
throw new ImportError("no_captions", `this video has no captions in "${opts.language ?? "any usable language"}". Available: ${available.slice(0, 20).join(", ")}${available.length > 20 ? `, … (${available.length} total)` : ""}.`, `Re-run picking one of those: clipy context import <url> --language ${available[0]}`);
}
throw new Error("this video has no captions (neither creator-provided nor auto-generated).\n" +
" Supply your own with --transcript <file.vtt|file.srt|transcript.json>.\n" +
" Local speech-to-text for caption-less videos lands in a future release.");
throw new ImportError("no_captions", "this video has no captions at all (neither creator-provided nor auto-generated).", `Supply your own: clipy context import <url> --transcript <file.vtt|file.srt|transcript.json>`);
}

@@ -154,7 +218,3 @@ const raw = captions.format === "json3"

});
notify(`Got ${transcript.segments.length} caption segments (${captions.source === "creator_captions" ? "creator-provided" : "auto-generated"}, ${captions.language}).`);
const source = {
kind: "youtube",
...(meta.id ? { canonicalUrl: `https://www.youtube.com/watch?v=${meta.id}`, providerId: meta.id } : {}),
};
notify(`Got ${transcript.segments.length} caption segments (${describeCaptions(captions)}).`);
return finish({

@@ -165,3 +225,3 @@ title: opts.title ?? meta.title,

transcript,
fingerprint: `youtube:${meta.id || url}`,
fingerprint: `youtube:${id}`,
media: { kind: "youtube", url, ytDlpBin: bin, durationMs: meta.durationMs },

@@ -173,13 +233,21 @@ opts,

if (!opts.transcriptPath) {
throw new Error(`local files need a transcript: pass --transcript <file.vtt|file.srt|transcript.json>.\n` +
" Local speech-to-text lands in a future release.");
throw new ImportError("no_captions", "local files need a transcript — Clipy cannot transcribe them yet.", `Re-run with a caption file: clipy context import ${JSON.stringify(path)} --transcript <file.vtt|file.srt|transcript.json>`);
}
const transcriptPath = resolve(opts.transcriptPath);
if (!existsSync(transcriptPath))
throw new Error(`--transcript ${transcriptPath} does not exist.`);
notify(`Probing ${basename(path)} with ffprobe…`);
const probe = await probeVideo(path);
let probe;
try {
probe = await probeVideo(path);
}
catch (e) {
const message = e.message;
if (/ffprobe was not found/i.test(message)) {
throw new ImportError("ffmpeg_missing", message, FFMPEG_INSTALL_COMMAND);
}
if (/no video stream/i.test(message)) {
throw new ImportError("no_video_stream", message, `Supply a file with a video track, or import the audio transcript-only with --no-frames.`);
}
throw new ImportError("source_unreadable", message, `Check the file plays, then re-run.`);
}
const contentHash = sha256File(path);
notify(`Reading the transcript from ${basename(transcriptPath)}…`);
const segments = parseTranscriptFile(transcriptPath);
const segments = loadUserTranscript(opts.transcriptPath);
notify(`Got ${segments.length} transcript segments (${fmtClock(probe.durationMs)} of video).`);

@@ -228,3 +296,3 @@ const transcript = buildNormalizedTranscript(segments, {

*/
function withVisualEvidence(compiled, classification, frames) {
function withVisualEvidence(compiled, classification, frames, completeness) {
const manifest = {

@@ -235,2 +303,3 @@ ...compiled.manifest,

...(frames.length > 0 ? { frames } : {}),
...(completeness ? { completeness } : {}),
};

@@ -336,2 +405,5 @@ return {

}
catch (e) {
throw new ImportError("server_unreachable", `could not reach the Clipy API at ${opts.apiUrl}: ${e.message}`, `Check your network, then re-run. Without --sync the local bundle is still written and readable with: clipy context read <bundle>`);
}
finally {

@@ -393,4 +465,3 @@ clearTimeout(timer);

if (!opts.apiKey) {
throw new Error(`--sync needs an API key. Run \`clipy login\` (or set CLIPY_API_KEY), then re-run.\n` +
` Nothing was lost: the local bundle is already written to ${bundlePath}.`);
throw new ImportError("auth_required", `--sync needs an API key, and none is configured. The local bundle is already written to ${bundlePath}.`, "clipy login", { bundlePath });
}

@@ -412,17 +483,16 @@ const payload = {

const detail = typeof body.error === "string" ? body.error : `HTTP ${status}`;
const intact = `Your local bundle is complete and unaffected: ${bundlePath}`;
const partial = { bundlePath };
if (status === 401) {
throw new Error(`sync failed while uploading the transcript: ${detail}\n` +
` Run \`clipy login\` to set a new key, then re-run.\n ${intact}`);
throw new ImportError("auth_required", `sync failed: ${detail}. Your local bundle is complete and unaffected: ${bundlePath}`, "clipy login", partial);
}
if (status === 403) {
throw new ImportError("wrong_scope", `sync failed: ${detail}. The key is valid but lacks the ingest scope. Your local bundle is complete and unaffected: ${bundlePath}`, "Mint a key with the ingest scope at https://clipy.online/settings/api-keys, then re-run with it.", partial);
}
if (status === 409) {
throw new Error(`sync failed while uploading the transcript: ${detail}\n` +
` That idempotency key already points at different content — this source changed since the last import.\n` +
` Delete the old document in your library, or import from a fresh copy.\n ${intact}`);
throw new ImportError("content_conflict", `sync failed: ${detail}. That idempotency key already points at different content — this source changed since the last import. Your local bundle is complete and unaffected: ${bundlePath}`, "Delete the old document in your library, or import from a fresh copy.", partial);
}
if (status === 429) {
throw new Error(`sync failed while uploading the transcript: ${detail}\n` +
` You have hit today's ingest quota (it resets at 00:00 UTC). Re-run then.\n ${intact}`);
throw new ImportError("quota_exceeded", `sync failed: ${detail}. You have hit today's ingest quota (it resets at 00:00 UTC). Your local bundle is complete and unaffected: ${bundlePath}`, "Re-run after 00:00 UTC. Do not retry in a loop — it only burns the limit.", partial);
}
throw new Error(`sync failed while uploading the transcript: ${detail}\n ${intact}`);
throw new ImportError("server_unreachable", `sync failed while uploading the transcript: ${detail}. Your local bundle is complete and unaffected: ${bundlePath}`, `Re-run the same command. The bundle is readable meanwhile: clipy context read ${bundlePath}`, partial);
}

@@ -501,5 +571,5 @@ return {

if (input.kind === "url") {
throw new Error("direct media URLs are not supported yet — pass a YouTube URL or download the file first and import it locally.");
throw new ImportError("invalid_url", "direct media URLs are not supported yet.", `Download the file first, then: clipy context import ./<file> --transcript <file.vtt>`);
}
let compiled = input.kind === "youtube" ? await compileYoutube(input.url, opts) : await compileLocal(input.path, opts);
let compiled = input.kind === "youtube" ? await compileYoutube(input.url, input.videoId, opts) : await compileLocal(input.path, opts);
const outputDir = resolve(opts.outputDir ?? process.cwd());

@@ -511,2 +581,28 @@ let { path: bundlePath, rewritten } = writeBundle(outputDir, compiled);

let frameUpload = null;
// Everything that went wrong WITHOUT costing the user the import. These ride
// out on a successful (exit 0) result — an agent must not read a missing
// frame as a failed import.
const warnings = [];
/**
* The bundle outlives the run, and the warning envelope does not — so the
* verdict has to be written INTO the document. "Transcript-only because the
* words were enough" and "transcript-only because the frames failed" look
* identical on disk otherwise.
*/
const completenessOf = (classification,
/** Frames that reached the DOCUMENT — not merely the local bundle. A failed
* upload leaves the images on disk but the document still can't show them. */
attachedFrames = 0) => {
const planned = classification.needsVisual ? classification.frameTimestampsMs.length : 0;
const missing = Math.max(0, planned - attachedFrames);
if (missing === 0)
return { status: "complete" };
const cause = warnings[0];
return {
status: "incomplete",
missingFrames: missing,
...(cause ? { reasonCode: cause.code, reason: cause.error } : {}),
rerunCommand: rerunCommand(target, opts),
};
};
if (opts.sync) {

@@ -538,2 +634,13 @@ // Phase 1: the transcript bundle goes up, the verdict comes back.

if (failure) {
warnings.push({
code: /403/.test(failure)
? "ytdlp_download_403"
: /ffmpeg|ffprobe/i.test(failure)
? "ffmpeg_missing"
: "frames_upload_failed",
error: `frames could not be extracted: ${failure.split("\n")[0]}`,
remediation: /ffmpeg|ffprobe/i.test(failure)
? FFMPEG_INSTALL_COMMAND
: rerunCommand(target, opts),
});
// Phase 1 already succeeded — this is a PARTIAL success, and saying

@@ -557,2 +664,7 @@ // "failed" here would send the user hunting for a document that is

catch (e) {
warnings.push({
code: "frames_upload_failed",
error: `the frames were cut but ${e.message}`,
remediation: rerunCommand(target, opts),
});
// Same partial-success rule as a failed extraction: the document

@@ -568,2 +680,7 @@ // exists, only the pictures are missing.

else if (frames.length > 0) {
warnings.push({
code: "frames_upload_failed",
error: "frames were extracted but the server returned no document id, so they were not uploaded.",
remediation: rerunCommand(target, opts),
});
notify("frames were extracted but the server returned no document id, so they were not uploaded.");

@@ -582,3 +699,5 @@ }

};
}));
}),
// frameUpload is set only when the server accepted them.
completenessOf(classification, frameUpload ? uploadedFrames.length : 0));
({ path: bundlePath } = writeBundle(outputDir, compiled, uploadedFrames));

@@ -593,3 +712,3 @@ }

if (uploadedFrames.length === 0) {
compiled = withVisualEvidence(compiled, classification, []);
compiled = withVisualEvidence(compiled, classification, [], completenessOf(classification));
({ path: bundlePath } = writeBundle(outputDir, compiled));

@@ -601,3 +720,8 @@ }

process.stdout.write(`${JSON.stringify({
ok: true,
bundlePath,
// The file an agent should actually open. Naming the directory alone
// makes every caller guess at the entry point.
contextPath: join(bundlePath, "recording.md"),
title: compiled.manifest.title,
profile: compiled.manifest.profile,

@@ -608,8 +732,21 @@ recommendedProfile: report?.recommendedProfile ?? null,

...(synced ? { synced: true, publicId: synced.publicId } : opts.sync ? { synced: false } : {}),
...(synced?.folderName ? { folderName: synced.folderName } : {}),
classification: synced?.classification ?? null,
frames: uploadedFrames.length,
...(frameUpload ? { framesAdded: frameUpload.added, framesTotal: frameUpload.total } : {}),
warnings,
}, null, 2)}\n`);
return;
}
// The headline. A user who ran one command should not have to assemble "did
// it work, and where is it" out of four key-value lines.
process.stdout.write(`\nYour agent-ready context for ${JSON.stringify(compiled.manifest.title)} is ready.\n`);
process.stdout.write(` → local bundle: ${join(bundlePath, "recording.md")} (point your agent here, or run: clipy context read ${bundlePath})\n`);
if (synced) {
process.stdout.write(` → in your Clipy library: filed under ${JSON.stringify(synced.folderName ?? "Knowledge Base")} — searchable, and readable by agents via MCP (read_context_document ${synced.publicId})\n`);
}
if (warnings.length > 0) {
process.stdout.write(` → incomplete: ${warnings.map((w) => w.error).join("; ")}\n to finish it: ${warnings[0].remediation}\n`);
}
process.stdout.write("\n");
process.stdout.write(`bundle: ${bundlePath}${rewritten ? " (rewritten — source content changed)" : ""}\n`);

@@ -632,3 +769,2 @@ if (synced?.classification) {

}
process.stdout.write(`next: clipy context read ${bundlePath}\n`);
}

@@ -14,2 +14,3 @@ /**

import { join } from "node:path";
import { BASELINE_DOWNLOAD_ARGS, DEFAULT_FORMAT, MAX_ATTEMPTS, PROGRESSIVE_FALLBACK, RETRY_BACKOFF_MS, TOKEN_FREE_CLIENT_ARGS, classifyFailure, isRetriable, retryNotice, sleep, } from "./retry.js";
/** A 720p video-only rendition of a long talk still runs to hundreds of MB;

@@ -69,3 +70,135 @@ * past this we are no longer "borrowing" the file. */

}
/** Leftovers from a failed attempt would be mistaken for a finished download. */
function clearDir(dir) {
for (const entry of readdirSync(dir))
rmSync(join(dir, entry), { recursive: true, force: true });
}
function attemptDownload(dir, input, plan) {
return new Promise((resolveRun, reject) => {
const child = spawn(input.ytDlpBin, [
"--ignore-config",
"--no-playlist",
"--no-part",
"--no-progress",
...BASELINE_DOWNLOAD_ARGS,
...plan.extraArgs,
"--max-filesize",
`${Math.floor(MAX_BYTES / 1_000_000)}M`,
"-f",
plan.format,
"-o",
join(dir, "source.%(ext)s"),
input.url,
], { stdio: ["ignore", "ignore", "pipe"] });
let stderr = "";
let aborted = null;
child.stderr.on("data", (d) => {
if (stderr.length < 16_384)
stderr += d.toString("utf8");
});
// --max-filesize only knows sizes the server declared up front, so watch
// the bytes actually landing too.
const poll = setInterval(() => {
try {
if (dirBytes(dir) > MAX_BYTES) {
aborted = `the download passed the ${Math.round(MAX_BYTES / 1_000_000_000)}GB ceiling`;
child.kill("SIGKILL");
}
}
catch {
// Directory already gone; the close handler will sort it out.
}
}, SIZE_POLL_MS);
const timer = setTimeout(() => {
aborted = "the download timed out";
child.kill("SIGKILL");
}, DOWNLOAD_TIMEOUT_MS);
const done = () => {
clearInterval(poll);
clearTimeout(timer);
};
child.on("error", (e) => {
done();
reject(e);
});
child.on("close", (code) => {
done();
if (aborted) {
resolveRun({ ok: false, abort: aborted });
return;
}
if (code !== 0) {
resolveRun({ ok: false, stderr: stderr.trim() || `exit ${code}` });
return;
}
resolveRun({ ok: true });
});
});
}
/**
* Three attempts on yt-dlp's own defaults, then two escalations.
*
* The 403 this exists for is not a verdict about the video — the identical
* command succeeds a minute later, because the media URL YouTube signed has
* expired and only a fresh extraction mints a new one. Re-running the binary IS
* the re-extraction, which is why the outer loop earns its keep even though
* BASELINE_DOWNLOAD_ARGS already makes yt-dlp retry internally.
*
* The escalation ladder and the reasoning behind each rung are documented in
* docs/research/2026-07-29-ytdlp-403-fallbacks.md §3.
*/
async function downloadWithRetries(dir, input) {
const base = { format: DEFAULT_FORMAT, extraArgs: [] };
let last = "";
const escalations = [
{
plan: { format: DEFAULT_FORMAT, extraArgs: TOKEN_FREE_CLIENT_ARGS },
notice: "Still blocked — trying YouTube's token-free player clients (tv, android_vr, web_embedded)…",
success: "A different player client worked — continuing with frame extraction.",
},
{
plan: PROGRESSIVE_FALLBACK,
notice: "Still blocked — falling back to a pre-muxed progressive format (lower resolution, but it downloads in one piece)…",
success: "The progressive format worked — continuing with frame extraction.",
},
];
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
if (attempt > 1)
clearDir(dir);
const result = await attemptDownload(dir, input, base);
if (result.ok)
return;
if ("abort" in result)
throw new VideoUnavailableError(`${result.abort}, so frames were skipped.`);
last = result.stderr;
const kind = classifyFailure(last);
if (!isRetriable(kind)) {
throw new VideoUnavailableError(`yt-dlp could not download the video for frame extraction: ${lastLine(last)}`);
}
if (attempt === MAX_ATTEMPTS)
break;
input.notify(retryNotice(last, kind, attempt + 1, MAX_ATTEMPTS));
await sleep(RETRY_BACKOFF_MS[attempt - 1] ?? 5_000);
}
for (const step of escalations) {
input.notify(step.notice);
clearDir(dir);
const result = await attemptDownload(dir, input, step.plan);
if (result.ok) {
input.notify(step.success);
return;
}
if ("abort" in result)
throw new VideoUnavailableError(`${result.abort}, so frames were skipped.`);
last = result.stderr;
// A fatal answer from a different client is the video's real answer.
if (!isRetriable(classifyFailure(last)))
break;
}
throw new VideoUnavailableError(`yt-dlp could not download the video for frame extraction: ${lastLine(last)}`);
}
function lastLine(stderr) {
return stderr.split("\n").filter(Boolean).pop() ?? "unknown error";
}
/**
* Downloads a bounded rendition of a YouTube video into a temp directory.

@@ -88,60 +221,3 @@ * Throws VideoUnavailableError when the source is out of bounds — callers treat

input.notify("Downloading a low-resolution (≤720p) copy of the video for frame extraction — it is deleted the moment the frames are cut…");
await new Promise((resolveRun, reject) => {
const child = spawn(input.ytDlpBin, [
"--ignore-config",
"--no-playlist",
"--no-part",
"--no-progress",
"--max-filesize",
`${Math.floor(MAX_BYTES / 1_000_000)}M`,
"-f",
"bv*[height<=720]/b[height<=720]",
"-o",
join(dir, "source.%(ext)s"),
input.url,
], { stdio: ["ignore", "ignore", "pipe"] });
let stderr = "";
let aborted = null;
child.stderr.on("data", (d) => {
if (stderr.length < 16_384)
stderr += d.toString("utf8");
});
// --max-filesize only knows sizes the server declared up front, so watch
// the bytes actually landing too.
const poll = setInterval(() => {
try {
if (dirBytes(dir) > MAX_BYTES) {
aborted = `the download passed the ${Math.round(MAX_BYTES / 1_000_000_000)}GB ceiling`;
child.kill("SIGKILL");
}
}
catch {
// Directory already gone; the close handler will sort it out.
}
}, SIZE_POLL_MS);
const timer = setTimeout(() => {
aborted = "the download timed out";
child.kill("SIGKILL");
}, DOWNLOAD_TIMEOUT_MS);
const done = () => {
clearInterval(poll);
clearTimeout(timer);
};
child.on("error", (e) => {
done();
reject(e);
});
child.on("close", (code) => {
done();
if (aborted) {
reject(new VideoUnavailableError(`${aborted}, so frames were skipped.`));
return;
}
if (code !== 0) {
reject(new VideoUnavailableError(`yt-dlp could not download the video for frame extraction: ${stderr.trim().split("\n").pop() ?? `exit ${code}`}`));
return;
}
resolveRun();
});
});
await downloadWithRetries(dir, input);
const file = readdirSync(dir).find((f) => f.startsWith("source."));

@@ -148,0 +224,0 @@ if (!file || !existsSync(join(dir, file)) || statSync(join(dir, file)).size === 0) {

@@ -10,9 +10,19 @@ /**

import { spawn } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync } from "node:fs";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, utimesSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { Readable } from "node:stream";
import { MAX_ATTEMPTS, RETRY_BACKOFF_MS, classifyFailure, isRetriable, retryNotice, sleep } from "./retry.js";
import { pipeline } from "node:stream/promises";
import { createWriteStream } from "node:fs";
const RELEASE_BASE = "https://github.com/yt-dlp/yt-dlp/releases/latest/download";
/**
* NIGHTLY, not stable. yt-dlp's own README calls stable "often stale and prone
* to external breakage" and names nightly "the recommended channel for regular
* users" — and YouTube-side breakage in 2026 has repeatedly been fixed the same
* week it appeared. A stable pin means shipping known-broken extraction for
* weeks. See docs/research/2026-07-29-ytdlp-403-fallbacks.md §4.
*/
const RELEASE_BASE = "https://github.com/yt-dlp/yt-dlp-nightly-builds/releases/latest/download";
/** Past this, our managed copy is refreshed before it is used. */
const MAX_BINARY_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const STDERR_CAP = 16_384;

@@ -120,2 +130,42 @@ export function clipyBinDir() {

let cached = null;
/**
* Refreshes OUR copy if it has gone stale.
*
* Deliberately never touches a yt-dlp the user installed themselves: `-U` on a
* Homebrew or pipx binary either fails or fights the package manager, and
* either way it is not ours to modify. Failure here is always swallowed — a
* stale binary might still work, and an unreachable GitHub must not block an
* import that would otherwise succeed.
*/
async function refreshManagedBinary(bin, notify) {
if (bin !== managedPath())
return;
let age;
try {
age = Date.now() - statSync(bin).mtimeMs;
}
catch {
return;
}
if (age < MAX_BINARY_AGE_MS)
return;
notify(`Your yt-dlp copy is ${Math.floor(age / (24 * 60 * 60 * 1000))} days old — updating it (YouTube breaks extraction often; this is Clipy's own copy, not a system install)…`);
try {
const res = await run(bin, ["-U"], 120_000);
notify(res.code === 0
? "yt-dlp is up to date."
: "yt-dlp could not update itself — continuing with the copy you have.");
}
catch {
notify("yt-dlp could not update itself — continuing with the copy you have.");
}
// Stamp it either way: a failed check should not be retried on every import.
try {
const now = new Date();
utimesSync(bin, now, now);
}
catch {
// Best effort.
}
}
export async function resolveYtDlp(notify) {

@@ -125,4 +175,6 @@ if (cached)

const managed = managedPath();
if (existsSync(managed))
if (existsSync(managed)) {
await refreshManagedBinary(managed, notify);
return (cached = managed);
}
const found = onPath();

@@ -165,3 +217,17 @@ if (found)

}
/** yt-dlp marks a video's own caption track with an -orig suffix. */
function baseLang(track) {
return track.replace(/-orig$/i, "");
}
/**
* The language the video was actually spoken in, if we can tell. The -orig
* track name is the strongest signal (YouTube adds it precisely to distinguish
* the source track from its ~200 auto-translations); the metadata `language`
* field is the fallback.
*/
function originalLanguage(meta) {
const tagged = [...meta.subtitleLangs, ...meta.autoCaptionLangs].find((l) => /-orig$/i.test(l));
return tagged ?? meta.language;
}
/**
* Picks one caption track and downloads only that track. Creator captions beat

@@ -182,43 +248,124 @@ * auto-captions; an exact language match beats a regional variant (en-US for

}
export async function fetchCaptions(bin, url, meta, langPref) {
/**
* Which caption track to try, in order. Exported for tests: the ranking is the
* whole feature, and it is worth asserting without a network round trip.
*
* An explicit --language is obeyed and nothing else is attempted — silently
* importing a different language than the one asked for would be worse than
* failing. Otherwise: creator captions beat machine ones, and within the
* machine ones the video's ORIGINAL language beats a translation of it,
* because YouTube's auto-translations are a translation OF a transcription and
* degrade twice over.
*/
export function planCaptionAttempts(meta, explicitLanguage) {
const attempts = [];
const creator = pickLang(meta.subtitleLangs, langPref);
if (creator)
attempts.push({ source: "creator_captions", lang: creator, flag: "--write-subs" });
const auto = pickLang(meta.autoCaptionLangs, langPref);
if (auto)
attempts.push({ source: "auto_captions", lang: auto, flag: "--write-auto-subs" });
const seen = new Set();
const orig = originalLanguage(meta);
const add = (track, kind) => {
if (!track || seen.has(`${kind}:${track}`))
return;
seen.add(`${kind}:${track}`);
const language = baseLang(track);
const translated = kind === "auto" && !!orig && baseLang(orig).toLowerCase() !== language.toLowerCase();
attempts.push({
track,
language,
source: kind === "creator"
? "creator_captions"
: translated
? "auto_captions_translated"
: "auto_captions",
...(translated ? { translatedFrom: baseLang(orig) } : {}),
flag: kind === "creator" ? "--write-subs" : "--write-auto-subs",
});
};
if (explicitLanguage) {
add(pickLang(meta.subtitleLangs, explicitLanguage), "creator");
add(pickLang(meta.autoCaptionLangs, explicitLanguage), "auto");
return attempts;
}
// 2. Any creator track, the original language for preference.
add(orig ? pickLang(meta.subtitleLangs, orig) : null, "creator");
add(meta.subtitleLangs[0], "creator");
// 3-5. Auto: original language, then English, then whatever exists.
add(orig ? pickLang(meta.autoCaptionLangs, orig) : null, "auto");
add(pickLang(meta.autoCaptionLangs, "en"), "auto");
add(meta.autoCaptionLangs[0], "auto");
return attempts;
}
/** One human-readable phrase for a track's provenance. */
export function describeCaptions(c) {
if (c.source === "creator_captions")
return `${c.language}, creator-provided`;
if (c.source === "auto_captions_translated") {
return `${c.language}, auto-translated from ${c.translatedFrom}`;
}
return `${c.language}, auto-generated — video's original language`;
}
export async function fetchCaptions(bin, url, meta, opts = {}) {
const attempts = planCaptionAttempts(meta, opts.language);
// A track can be listed and still refuse to download — YouTube rate-limits
// (HTTP 429) under exactly the retry patterns an import produces. Reporting
// that as "this video has no captions" sends the user to fix the wrong thing.
let lastFailure = null;
for (const attempt of attempts) {
for (const format of ["json3", "vtt"]) {
const dir = mkdtempSync(join(tmpdir(), "clipy-subs-"));
try {
const res = await run(bin, [
"--ignore-config",
"--no-playlist",
"--skip-download",
attempt.flag,
"--sub-langs",
attempt.lang,
"--sub-format",
format,
"-o",
join(dir, "track.%(ext)s"),
url,
], 180_000);
if (res.code !== 0)
continue;
const file = readdirSync(dir).find((f) => f.endsWith(`.${format}`));
if (!file)
continue;
const text = readFileSync(join(dir, file), "utf8");
if (!text.trim())
continue;
return { format, text, source: attempt.source, language: attempt.lang };
opts.notify?.(`Downloading captions (${describeCaptions(attempt)})…`);
// Retrying the PREFERRED track before dropping to the next language is what
// keeps re-runs idempotent: a transient 403/429 on the original-language
// track must not silently promote a machine translation, because that would
// change the transcript under a bundle fingerprint that has not changed and
// the server would reject the re-upload as a content conflict.
for (let tryN = 1; tryN <= MAX_ATTEMPTS; tryN += 1) {
let stderr = "";
for (const format of ["json3", "vtt"]) {
const dir = mkdtempSync(join(tmpdir(), "clipy-subs-"));
try {
const res = await run(bin, [
"--ignore-config",
"--no-playlist",
"--skip-download",
attempt.flag,
"--sub-langs",
attempt.track,
"--sub-format",
format,
"-o",
join(dir, "track.%(ext)s"),
url,
], 180_000);
if (res.code !== 0) {
stderr = res.stderr.trim();
const line = stderr.split("\n").filter(Boolean).pop();
if (line)
lastFailure = `${attempt.track}: ${line}`;
continue;
}
const file = readdirSync(dir).find((f) => f.endsWith(`.${format}`));
if (!file)
continue;
const text = readFileSync(join(dir, file), "utf8");
if (!text.trim())
continue;
return {
format,
text,
source: attempt.source,
language: attempt.language,
...(attempt.translatedFrom ? { translatedFrom: attempt.translatedFrom } : {}),
};
}
finally {
rmSync(dir, { recursive: true, force: true });
}
}
finally {
rmSync(dir, { recursive: true, force: true });
}
const kind = classifyFailure(stderr);
if (!stderr || !isRetriable(kind) || tryN === MAX_ATTEMPTS)
break;
opts.notify?.(retryNotice(stderr, kind, tryN + 1, MAX_ATTEMPTS));
await sleep(RETRY_BACKOFF_MS[tryN - 1] ?? 5_000);
}
}
if (lastFailure)
opts.onFailure?.(lastFailure);
return null;
}

@@ -85,2 +85,104 @@ /**

### Reading an import without drowning in it
Imports are often an hour or more. Read them in widening passes, not all at once:
1. **Metadata + classification first.** \`clipy context read <bundle>\` starts with
the header: source, duration, the video type, whether the words stand alone,
and the timestamps the classifier flagged as blind. For a synced document,
\`get_context_document\` (MCP) returns the same thing WITHOUT the transcript —
that is the cheapest possible orientation, one tool call.
2. **Then the sections you actually need.** The document is timestamped in
\`[MM:SS]\` sections. Once the summary tells you where the answer lives, read
that span — over MCP, \`read_context_document\` takes \`startMs\`/\`endMs\` and
returns only the sections overlapping your range, so a two-hour video costs you
two minutes of context instead of two hours. It tells you how many sections it
withheld, so you always know you're looking at a slice.
3. **Frames last, and only where the words are blind.** Frames exist for exactly
the moments the classifier said the transcript can't carry ("click this", "the
config looks like this"). Pull them for those timestamps; don't page through
every frame hoping one is useful.
Whole-document reads are for short videos (under ~10 minutes) or when you genuinely
need every word. Assume you don't until the targeted read comes back empty.
## When something goes wrong
Two rules that come before any specific error:
**\`--json\` on stdout is the source of truth. stderr is narration.** Progress
lines, warnings, and install disclosures go to stderr and are NOT part of the
contract — never parse them, never conclude success or failure from them. With
\`--json\`, stdout carries either the result or an error envelope:
{"ok": false, "code": "<stable code>", "error": "…", "remediation": "…", "partial": null}
Branch on \`code\`, not on the message text (messages get reworded; codes don't).
\`remediation\` is the next action, written to be run verbatim where possible.
\`partial\` carries whatever survived (e.g. \`{"bundlePath": "…"}\`) or is null.
A SUCCESS is \`{"ok": true, …}\` and always carries \`warnings\` — an array, empty
when nothing went wrong. A partial success (transcript synced, frames missing) is
\`ok: true\` with entries in \`warnings\`, each \`{code, error, remediation}\`, and
exits \`0\`. Read the warnings; do not read them as failure.
Exit codes: \`0\` ok (including partial success) · \`1\` error · \`2\` usage ·
\`3\` artifact not ready.
**Never invent success.** If the command did not print a result you can read, the
work did not happen. Do not tell the user a video was imported, a recording was
made, or frames were captured because the command "seemed to run". Report what the
envelope says, including partial states.
### The error codes
| code | what happened | what to do |
|---|---|---|
| \`invalid_url\` | the URL isn't a video Clipy can resolve | Re-read the URL with the user. Don't retry the same string. |
| \`no_captions\` | the YouTube video has no captions in any language | Nothing to transcribe from. Tell the user; offer to import a local file with \`--transcript\`, or to proceed without the video. Not retryable. |
| \`ytdlp_missing\` | yt-dlp couldn't be installed or resolved | \`clipy doctor --json\` names the path it tried. Fix: let it auto-install (it lands in \`~/.clipy/bin\`), or install manually — \`brew install yt-dlp\` / \`pipx install yt-dlp\`. Then re-run. |
| \`ytdlp_download_403\` | YouTube refused the media download | **The CLI already retried internally.** If you still see this, the transcript half may have succeeded — read the envelope for what synced. Tell the user frames are pending and the document is usable without them. Do NOT loop. |
| \`ffmpeg_missing\` | ffmpeg/ffprobe not found | \`brew install ffmpeg\` (macOS) · \`sudo apt install ffmpeg\` (Linux) · \`winget install Gyan.FFmpeg\`. Then re-run the SAME import command. |
| \`no_video_stream\` | the file has no decodable video track | Audio-only file. Import it transcript-only (\`--no-frames\`) or supply the real video. |
| \`auth_required\` | 401 — no key, or the key is invalid/revoked | Run \`clipy login\`, then **tell the user to approve the device in the browser that just opened and wait for them**. Do not retry the import until login returns. On a headless box use \`clipy login --no-browser\`. |
| \`wrong_scope\` | 403 — the key is real but lacks a permission | Write paths need the "ingest" scope. Mint a key with it at clipy.online/settings/api-keys. Re-running with the same key cannot help. |
| \`quota_exceeded\` | 429 — the account hit a limit | **Report the number to the user and stop.** Do not retry-loop; you will only burn the limit. The local bundle (if one was produced) is still yours to read. |
| \`frames_upload_failed\` | transcript synced, frames didn't | Partial success — see below. Re-run the same command later. |
| \`server_unreachable\` | the API couldn't be reached | Check \`clipy doctor --json\`'s api check. If the network is down, say so; the LOCAL bundle from a non-\`--sync\` run is still complete and readable with \`clipy context read\`. |
| \`transcript_unreadable\` | the \`--transcript\` file could not be parsed | Check it is real \`.vtt\`/\`.srt\`/Clipy transcript JSON and not empty or an HTML error page. Ask the user for the right file rather than guessing another path. |
| \`source_unreadable\` | the local video file could not be opened or probed | Verify the path exists and is readable (unmounted volume, still downloading, wrong container). Not retryable until the file is. |
| \`content_conflict\` | a different video already occupies that document | Do NOT overwrite. Show the user both and let them choose; re-running unchanged conflicts again. |
| \`unknown\` | an unclassified failure | Re-run once. If it repeats, hand the user the whole envelope as a bug report — do not improvise a workaround. |
### The partial-success rule
**A transcript that synced with frames missing is a usable result, not a failure.**
The document exists, it's readable, and it answers most questions. When you see it:
- Tell the user plainly: imported and readable, frames pending, here's what's
missing (the classifier already named those timestamps).
- To complete it, **re-run the SAME \`clipy context import\` command** on the same
source. Imports are idempotent: it resolves to the same document and fills in
what's missing.
- **Never re-import from scratch** — no new \`--title\`, no different output dir, no
"let me try a fresh one". That produces a duplicate document and loses nothing
you gain.
- Never delete the partial document to "clean up" before retrying.
### The three you'll actually hit
- **401 / \`auth_required\`** → \`clipy login\` opens a browser. The user has to click
approve. Wait for the command to return before doing anything else, and say out
loud that you're waiting on their browser — an agent silently blocking on a
login looks like a hang.
- **429 / \`quota_exceeded\`** → surface the quota to the user and stop. This is a
billing/limit fact, not a transient error.
- **403 on download / \`ytdlp_download_403\`** → the CLI has already retried behind
your back. Treat the import as done-but-incomplete, tell the user frames are
pending, and move on with the transcript.
When you can't tell which of these you're in, run \`clipy doctor --json\` — it
reports yt-dlp, ffmpeg, auth, and API reachability in one call and names the
missing piece instead of leaving you to guess.
## Setup for making recordings (one time)

@@ -87,0 +189,0 @@

{
"name": "@clipy/cli",
"version": "0.9.1",
"version": "0.9.2",
"description": "Command-line interface for Clipy — list, search, and read your screen recordings' transcripts, AI summaries, and key moments from the terminal.",

@@ -43,5 +43,5 @@ "license": "MIT",

"test:session": "npm run build && node scripts/auth-guard.test.mjs && node scripts/session-control.test.mjs",
"test": "npm run test:auth && npm run test:session && node scripts/context-sync.test.mjs && node scripts/context-frames.test.mjs",
"test": "npm run test:auth && npm run test:session && node scripts/context-sync.test.mjs && node scripts/context-frames.test.mjs && node scripts/context-youtube-transcript.test.mjs && node scripts/context-youtube-url-lang.test.mjs && node scripts/context-retry.test.mjs && node scripts/context-json-envelope.test.mjs && node scripts/context-ytdlp-update.test.mjs",
"prebuild": "node scripts/sync-context-core.mjs",
"test:context": "npm run build && node scripts/context-sync.test.mjs && node scripts/context-frames.test.mjs"
"test:context": "npm run build && node scripts/context-sync.test.mjs && node scripts/context-frames.test.mjs && node scripts/context-youtube-transcript.test.mjs && node scripts/context-youtube-url-lang.test.mjs && node scripts/context-retry.test.mjs && node scripts/context-json-envelope.test.mjs && node scripts/context-ytdlp-update.test.mjs"
},

@@ -48,0 +48,0 @@ "devDependencies": {

Sorry, the diff of this file is too big to display