@youtubesearch/cli 0.1.2

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/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # @youtubesearch/cli
2
+
3
+ Agent-optimized YouTube search, transcripts, and summaries from your shell —
4
+ the CLI for the [YouTubeSearch](https://youtubesearch.dev) API. Results to
5
+ stdout, diagnostics to stderr, `--json` everywhere, meaningful exit codes.
6
+
7
+ ```bash
8
+ npx @youtubesearch/cli search "karpathy intro to large language models" --limit 5
9
+ npx @youtubesearch/cli video zjkBMFhNj_g
10
+ npx @youtubesearch/cli transcript zjkBMFhNj_g --start 1663 --end 2012 --format markdown
11
+ npx @youtubesearch/cli summary zjkBMFhNj_g --sections executive_summary,key_points
12
+ ```
13
+
14
+ All of the above work **keyless** on cached content. For everything else, get
15
+ a free key (1,000 credits/month) at [youtubesearch.dev](https://youtubesearch.dev):
16
+
17
+ ```bash
18
+ npx @youtubesearch/cli auth login # stores the key in your config dir, mode 0600
19
+ npx @youtubesearch/cli auth status # shows the key + credit balance
20
+ ```
21
+
22
+ ## Commands
23
+
24
+ | Command | Does |
25
+ |---|---|
26
+ | `search <query>` | Search videos (`--limit`, `--duration`, `--recency`, `--channel`) |
27
+ | `video <id>` | Metadata, stats, chapters |
28
+ | `transcript <id>` | Timestamped transcript (`--format`, `--start`, `--end`, `--max-tokens`) |
29
+ | `summary <id>` | Structured summary (`--sections a,b,c`) |
30
+ | `auth login` / `auth status` | Manage the stored API key |
31
+
32
+ Exit codes: `0` success · `1` API/network error · `2` usage error · `3` auth error.
33
+
34
+ ## Environment
35
+
36
+ | Var | What |
37
+ |---|---|
38
+ | `YOUTUBESEARCH_API_KEY` | API key; overrides the stored config |
39
+ | `YOUTUBESEARCH_API_BASE` | API base URL (default `https://api.youtubesearch.dev`) |
40
+
41
+ ## From source
42
+
43
+ Node ≥ 20, zero runtime dependencies:
44
+
45
+ ```bash
46
+ npm ci && npm run build
47
+ node dist/cli.js --help
48
+ ```
49
+
50
+ API docs: [youtubesearch.dev/docs](https://youtubesearch.dev/docs) ·
51
+ MIT © 2026 YouTubeSearch
package/dist/api.js ADDED
@@ -0,0 +1,66 @@
1
+ // Fetch wrapper for the YouTubeSearch REST API + typed error handling.
2
+ const DEFAULT_BASE = "https://api.youtubesearch.dev";
3
+ export function apiBase() {
4
+ const override = process.env.YOUTUBESEARCH_API_BASE;
5
+ const base = override && override.trim() !== "" ? override.trim() : DEFAULT_BASE;
6
+ return base.replace(/\/+$/, "");
7
+ }
8
+ /** An error response from the API (wire format {error, message}) or a network failure (status 0). */
9
+ export class ApiError extends Error {
10
+ code;
11
+ status;
12
+ retryAfter;
13
+ constructor(code, message, status, retryAfter = null) {
14
+ super(message);
15
+ this.code = code;
16
+ this.status = status;
17
+ this.retryAfter = retryAfter;
18
+ this.name = "ApiError";
19
+ }
20
+ }
21
+ export async function apiRequest(path, key, options = {}) {
22
+ const url = new URL(apiBase() + path);
23
+ for (const [name, value] of Object.entries(options.query ?? {})) {
24
+ if (value !== undefined)
25
+ url.searchParams.set(name, String(value));
26
+ }
27
+ const headers = { accept: "application/json" };
28
+ if (key)
29
+ headers.authorization = `Bearer ${key}`;
30
+ if (options.body !== undefined)
31
+ headers["content-type"] = "application/json";
32
+ let res;
33
+ try {
34
+ res = await fetch(url, {
35
+ method: options.method ?? "GET",
36
+ headers,
37
+ body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
38
+ });
39
+ }
40
+ catch (err) {
41
+ throw new ApiError("NETWORK", describeFetchError(err), 0);
42
+ }
43
+ const text = await res.text();
44
+ let data;
45
+ try {
46
+ data = text === "" ? undefined : JSON.parse(text);
47
+ }
48
+ catch {
49
+ data = undefined;
50
+ }
51
+ if (!res.ok) {
52
+ const body = (data ?? {});
53
+ const code = typeof body.error === "string" ? body.error : `HTTP_${res.status}`;
54
+ const message = typeof body.message === "string" ? body.message : text || res.statusText || "request failed";
55
+ throw new ApiError(code, message, res.status, res.headers.get("retry-after"));
56
+ }
57
+ return data;
58
+ }
59
+ function describeFetchError(err) {
60
+ if (err instanceof Error) {
61
+ const cause = err.cause;
62
+ const causeMessage = cause instanceof Error ? cause.message : undefined;
63
+ return causeMessage ? `${err.message} (${causeMessage})` : err.message;
64
+ }
65
+ return String(err);
66
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,372 @@
1
+ #!/usr/bin/env node
2
+ // youtubesearch — agent-facing CLI for the YouTubeSearch REST API.
3
+ // Results to stdout, diagnostics to stderr.
4
+ // Exit codes: 0 success, 1 API/network error, 2 usage error, 3 auth error.
5
+ import { readFileSync } from "node:fs";
6
+ import { createInterface } from "node:readline";
7
+ import { parseArgs } from "node:util";
8
+ import { ApiError, apiBase, apiRequest } from "./api.js";
9
+ import { configPath, displayKey, readConfig, resolveKey, writeConfig } from "./config.js";
10
+ import { renderCredits, renderSearch, renderSummary, renderVideo } from "./render.js";
11
+ // A closed pipe (e.g. `youtubesearch ... | head`) is a normal way for a
12
+ // consumer to stop reading — exit quietly instead of crashing.
13
+ process.stdout.on("error", (err) => {
14
+ if (err.code === "EPIPE")
15
+ process.exit(0);
16
+ throw err;
17
+ });
18
+ class UsageError extends Error {
19
+ }
20
+ const TOP_USAGE = `Usage: youtubesearch <command> [options]
21
+
22
+ Agent-optimized YouTube search, transcripts, and summaries.
23
+
24
+ Commands:
25
+ search <query> Search videos (POST /v1/search)
26
+ video <id> Video metadata: stats, chapters (GET /v1/videos/{id})
27
+ transcript <id> Timestamped transcript (GET /v1/videos/{id}/transcript)
28
+ summary <id> Structured summary (GET /v1/videos/{id}/summary)
29
+ auth login Store an API key in the local config
30
+ auth status Show the configured key and credit balance
31
+
32
+ Options:
33
+ --help Show this help (each command also accepts --help)
34
+ --version Print the CLI version
35
+
36
+ Environment:
37
+ YOUTUBESEARCH_API_KEY API key; overrides the stored config
38
+ YOUTUBESEARCH_API_BASE API base URL (default ${apiBase()})
39
+
40
+ Without a key the CLI still works on the keyless tier (cached content only).
41
+ Exit codes: 0 success, 1 API or network error, 2 usage error, 3 auth error.
42
+ `;
43
+ const SEARCH_USAGE = `Usage: youtubesearch search <query> [options]
44
+
45
+ Options:
46
+ --limit <n> Max results (server default 10, max 20)
47
+ --duration <short|medium|long> Filter by video length
48
+ --recency <hour|day|week|month|year> Filter by upload time
49
+ --channel <name> Filter by channel
50
+ --json Print the raw JSON response
51
+ --help Show this help
52
+ `;
53
+ const VIDEO_USAGE = `Usage: youtubesearch video <id> [options]
54
+
55
+ Options:
56
+ --json Print the raw JSON response
57
+ --help Show this help
58
+ `;
59
+ const TRANSCRIPT_USAGE = `Usage: youtubesearch transcript <id> [options]
60
+
61
+ Options:
62
+ --format <markdown|json|plain> Output format (default markdown)
63
+ --start <seconds> Window start
64
+ --end <seconds> Window end
65
+ --max-tokens <n> Truncate to roughly this many tokens
66
+ --help Show this help
67
+
68
+ markdown/plain print the transcript text; json prints the full JSON response.
69
+ `;
70
+ const SUMMARY_USAGE = `Usage: youtubesearch summary <id> [options]
71
+
72
+ Options:
73
+ --sections <a,b,c> Comma-separated section names (e.g. key_points,insights)
74
+ --json Print the raw JSON response
75
+ --help Show this help
76
+ `;
77
+ const AUTH_USAGE = `Usage: youtubesearch auth <login|status>
78
+
79
+ Subcommands:
80
+ login [--key ys_live_...] Store an API key (reads stdin when --key is absent)
81
+ status Show the configured key and credit balance
82
+ `;
83
+ const AUTH_LOGIN_USAGE = `Usage: youtubesearch auth login [--key ys_live_...]
84
+
85
+ Stores the key in ${configPath()} (mode 0600).
86
+ When --key is absent the key is read from stdin.
87
+ `;
88
+ const AUTH_STATUS_USAGE = `Usage: youtubesearch auth status
89
+
90
+ Shows whether a key is configured and its credit balance (GET /v1/credits).
91
+ `;
92
+ /**
93
+ * Subcommand pattern: the command name has already been shifted off, so the
94
+ * remaining args (flags after the subcommand) get a strict parse.
95
+ */
96
+ function parseFlags(args, flags) {
97
+ try {
98
+ const { values, positionals } = parseArgs({
99
+ args,
100
+ options: { ...flags, help: { type: "boolean" } },
101
+ strict: true,
102
+ allowPositionals: true,
103
+ });
104
+ return { values, positionals };
105
+ }
106
+ catch (err) {
107
+ throw new UsageError(err instanceof Error ? err.message : String(err));
108
+ }
109
+ }
110
+ function parseNumberFlag(value, flag) {
111
+ if (value === undefined)
112
+ return undefined;
113
+ const n = Number(value);
114
+ if (typeof value !== "string" || value.trim() === "" || !Number.isFinite(n)) {
115
+ throw new UsageError(`${flag} expects a number, got '${String(value)}'`);
116
+ }
117
+ return n;
118
+ }
119
+ function parseChoiceFlag(value, flag, choices) {
120
+ if (value === undefined)
121
+ return undefined;
122
+ if (typeof value !== "string" || !choices.includes(value)) {
123
+ throw new UsageError(`${flag} must be one of: ${choices.join(", ")}`);
124
+ }
125
+ return value;
126
+ }
127
+ function requireId(positionals, usage) {
128
+ const id = positionals[0];
129
+ if (!id)
130
+ throw new UsageError(`missing <id> argument\n${usage.trimEnd()}`);
131
+ if (positionals.length > 1) {
132
+ throw new UsageError(`unexpected extra argument '${positionals[1]}'`);
133
+ }
134
+ return id;
135
+ }
136
+ function printJson(data) {
137
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
138
+ }
139
+ function readLineFromStdin() {
140
+ return new Promise((resolve) => {
141
+ const rl = createInterface({ input: process.stdin });
142
+ let done = false;
143
+ rl.once("line", (line) => {
144
+ done = true;
145
+ rl.close();
146
+ resolve(line);
147
+ });
148
+ rl.once("close", () => {
149
+ if (!done)
150
+ resolve("");
151
+ });
152
+ });
153
+ }
154
+ function cliVersion() {
155
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
156
+ return pkg.version ?? "0.0.0";
157
+ }
158
+ // ---------------------------------------------------------------- commands
159
+ async function cmdAuthLogin(args) {
160
+ const { values } = parseFlags(args, { key: { type: "string" } });
161
+ if (values.help) {
162
+ process.stdout.write(AUTH_LOGIN_USAGE);
163
+ return 0;
164
+ }
165
+ let key = typeof values.key === "string" ? values.key.trim() : "";
166
+ if (key === "") {
167
+ if (process.stdin.isTTY)
168
+ process.stderr.write("Enter your API key (ys_live_...): ");
169
+ key = (await readLineFromStdin()).trim();
170
+ }
171
+ if (key === "") {
172
+ throw new UsageError("no API key provided (pass --key or pipe the key on stdin)");
173
+ }
174
+ const config = readConfig();
175
+ config.api_key = key;
176
+ writeConfig(config);
177
+ process.stdout.write(`Saved API key ${displayKey(key)} to ${configPath()}\n`);
178
+ return 0;
179
+ }
180
+ async function cmdAuthStatus(args) {
181
+ const { values } = parseFlags(args, {});
182
+ if (values.help) {
183
+ process.stdout.write(AUTH_STATUS_USAGE);
184
+ return 0;
185
+ }
186
+ const { key, source } = resolveKey();
187
+ if (!key) {
188
+ process.stdout.write("No API key configured (keyless tier: cached content only).\n" +
189
+ "Run 'youtubesearch auth login' to add one.\n");
190
+ return 0;
191
+ }
192
+ const from = source === "env" ? "YOUTUBESEARCH_API_KEY env var" : configPath();
193
+ process.stdout.write(`API key: ${displayKey(key)} (from ${from})\n`);
194
+ const credits = await apiRequest("/v1/credits", key);
195
+ process.stdout.write(renderCredits(credits));
196
+ return 0;
197
+ }
198
+ async function cmdSearch(args) {
199
+ const { values, positionals } = parseFlags(args, {
200
+ limit: { type: "string" },
201
+ duration: { type: "string" },
202
+ recency: { type: "string" },
203
+ channel: { type: "string" },
204
+ json: { type: "boolean" },
205
+ });
206
+ if (values.help) {
207
+ process.stdout.write(SEARCH_USAGE);
208
+ return 0;
209
+ }
210
+ const query = positionals.join(" ").trim();
211
+ if (query === "")
212
+ throw new UsageError(`missing <query> argument\n${SEARCH_USAGE.trimEnd()}`);
213
+ const limit = parseNumberFlag(values.limit, "--limit");
214
+ const duration = parseChoiceFlag(values.duration, "--duration", ["short", "medium", "long"]);
215
+ const recency = parseChoiceFlag(values.recency, "--recency", [
216
+ "hour",
217
+ "day",
218
+ "week",
219
+ "month",
220
+ "year",
221
+ ]);
222
+ const channel = typeof values.channel === "string" ? values.channel : undefined;
223
+ const body = { query };
224
+ if (limit !== undefined)
225
+ body.limit = limit;
226
+ if (duration || recency || channel)
227
+ body.filters = { duration, recency, channel };
228
+ const { key } = resolveKey();
229
+ const result = await apiRequest("/v1/search", key, { method: "POST", body });
230
+ if (values.json)
231
+ printJson(result);
232
+ else
233
+ process.stdout.write(renderSearch(result));
234
+ return 0;
235
+ }
236
+ async function cmdVideo(args) {
237
+ const { values, positionals } = parseFlags(args, { json: { type: "boolean" } });
238
+ if (values.help) {
239
+ process.stdout.write(VIDEO_USAGE);
240
+ return 0;
241
+ }
242
+ const id = requireId(positionals, VIDEO_USAGE);
243
+ const { key } = resolveKey();
244
+ const result = await apiRequest(`/v1/videos/${encodeURIComponent(id)}`, key);
245
+ if (values.json)
246
+ printJson(result);
247
+ else
248
+ process.stdout.write(renderVideo(result));
249
+ return 0;
250
+ }
251
+ async function cmdTranscript(args) {
252
+ const { values, positionals } = parseFlags(args, {
253
+ format: { type: "string" },
254
+ start: { type: "string" },
255
+ end: { type: "string" },
256
+ "max-tokens": { type: "string" },
257
+ });
258
+ if (values.help) {
259
+ process.stdout.write(TRANSCRIPT_USAGE);
260
+ return 0;
261
+ }
262
+ const id = requireId(positionals, TRANSCRIPT_USAGE);
263
+ const format = parseChoiceFlag(values.format, "--format", ["markdown", "json", "plain"]) ?? "markdown";
264
+ const { key } = resolveKey();
265
+ const result = await apiRequest(`/v1/videos/${encodeURIComponent(id)}/transcript`, key, {
266
+ query: {
267
+ format,
268
+ start: parseNumberFlag(values.start, "--start"),
269
+ end: parseNumberFlag(values.end, "--end"),
270
+ max_tokens: parseNumberFlag(values["max-tokens"], "--max-tokens"),
271
+ },
272
+ });
273
+ if (format === "json") {
274
+ printJson(result);
275
+ return 0;
276
+ }
277
+ const payload = (result ?? {});
278
+ if (payload.truncated === true) {
279
+ process.stderr.write("note: transcript truncated (--max-tokens)\n");
280
+ }
281
+ const text = typeof payload.transcript === "string" ? payload.transcript : "";
282
+ process.stdout.write(text.endsWith("\n") || text === "" ? text : text + "\n");
283
+ return 0;
284
+ }
285
+ async function cmdSummary(args) {
286
+ const { values, positionals } = parseFlags(args, {
287
+ sections: { type: "string" },
288
+ json: { type: "boolean" },
289
+ });
290
+ if (values.help) {
291
+ process.stdout.write(SUMMARY_USAGE);
292
+ return 0;
293
+ }
294
+ const id = requireId(positionals, SUMMARY_USAGE);
295
+ const sections = typeof values.sections === "string" ? values.sections : undefined;
296
+ const { key } = resolveKey();
297
+ const result = await apiRequest(`/v1/videos/${encodeURIComponent(id)}/summary`, key, {
298
+ query: { sections },
299
+ });
300
+ if (values.json)
301
+ printJson(result);
302
+ else
303
+ process.stdout.write(renderSummary(result));
304
+ return 0;
305
+ }
306
+ // ---------------------------------------------------------------- dispatch
307
+ async function run(argv) {
308
+ const command = argv[0];
309
+ if (command === undefined) {
310
+ process.stderr.write(TOP_USAGE);
311
+ return 2;
312
+ }
313
+ if (command === "--help" || command === "-h" || command === "help") {
314
+ process.stdout.write(TOP_USAGE);
315
+ return 0;
316
+ }
317
+ if (command === "--version" || command === "-v") {
318
+ process.stdout.write(cliVersion() + "\n");
319
+ return 0;
320
+ }
321
+ const rest = argv.slice(1);
322
+ switch (command) {
323
+ case "auth": {
324
+ const sub = rest[0];
325
+ if (sub === "login")
326
+ return cmdAuthLogin(rest.slice(1));
327
+ if (sub === "status")
328
+ return cmdAuthStatus(rest.slice(1));
329
+ if (sub === "--help" || sub === "-h") {
330
+ process.stdout.write(AUTH_USAGE);
331
+ return 0;
332
+ }
333
+ throw new UsageError(sub === undefined ? `missing auth subcommand\n${AUTH_USAGE.trimEnd()}` : `unknown auth subcommand '${sub}'`);
334
+ }
335
+ case "search":
336
+ return cmdSearch(rest);
337
+ case "video":
338
+ return cmdVideo(rest);
339
+ case "transcript":
340
+ return cmdTranscript(rest);
341
+ case "summary":
342
+ return cmdSummary(rest);
343
+ default:
344
+ throw new UsageError(`unknown command '${command}'\nRun 'youtubesearch --help' for usage.`);
345
+ }
346
+ }
347
+ async function main() {
348
+ try {
349
+ process.exitCode = await run(process.argv.slice(2));
350
+ }
351
+ catch (err) {
352
+ if (err instanceof UsageError) {
353
+ process.stderr.write(`error: ${err.message}\n`);
354
+ process.exitCode = 2;
355
+ return;
356
+ }
357
+ if (err instanceof ApiError) {
358
+ process.stderr.write(`${err.code}: ${err.message}\n`);
359
+ if (err.status === 401 && err.code === "KEY_REQUIRED") {
360
+ process.stderr.write("hint: run 'youtubesearch auth login' to configure an API key\n");
361
+ }
362
+ if ((err.status === 402 || err.status === 429) && err.retryAfter) {
363
+ process.stderr.write(`retry-after: ${err.retryAfter}s\n`);
364
+ }
365
+ process.exitCode = err.status === 401 || err.status === 403 ? 3 : 1;
366
+ return;
367
+ }
368
+ process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
369
+ process.exitCode = 1;
370
+ }
371
+ }
372
+ void main();
package/dist/config.js ADDED
@@ -0,0 +1,56 @@
1
+ // XDG config read/write for the stored API key.
2
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ export function configDir() {
6
+ if (process.platform === "win32") {
7
+ const base = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
8
+ return join(base, "youtubesearch");
9
+ }
10
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
11
+ return join(base, "youtubesearch");
12
+ }
13
+ export function configPath() {
14
+ return join(configDir(), "config.json");
15
+ }
16
+ export function readConfig() {
17
+ try {
18
+ const parsed = JSON.parse(readFileSync(configPath(), "utf8"));
19
+ if (parsed !== null && typeof parsed === "object")
20
+ return parsed;
21
+ }
22
+ catch {
23
+ // Missing or unreadable config is the same as no config.
24
+ }
25
+ return {};
26
+ }
27
+ export function writeConfig(config) {
28
+ mkdirSync(configDir(), { recursive: true, mode: 0o700 });
29
+ const file = configPath();
30
+ writeFileSync(file, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
31
+ // writeFileSync's mode only applies when creating the file; enforce it on
32
+ // overwrite too so the key never sits in a group/world-readable file.
33
+ chmodSync(file, 0o600);
34
+ }
35
+ /** Resolve the API key: YOUTUBESEARCH_API_KEY env var wins over the config file. */
36
+ export function resolveKey() {
37
+ const envKey = process.env.YOUTUBESEARCH_API_KEY;
38
+ if (envKey)
39
+ return { key: envKey, source: "env" };
40
+ const fileKey = readConfig().api_key;
41
+ if (fileKey)
42
+ return { key: fileKey, source: "config" };
43
+ return {};
44
+ }
45
+ /**
46
+ * Display form of a key: prefix + first 4 and last 4 chars of the random part.
47
+ * Never returns the full key.
48
+ */
49
+ export function displayKey(key) {
50
+ const match = /^(ys_[a-z]+_)(.*)$/.exec(key);
51
+ const prefix = match ? match[1] : "";
52
+ const random = match ? match[2] : key;
53
+ if (random.length <= 8)
54
+ return `${prefix}${"*".repeat(random.length)}`;
55
+ return `${prefix}${random.slice(0, 4)}...${random.slice(-4)}`;
56
+ }
package/dist/render.js ADDED
@@ -0,0 +1,117 @@
1
+ // Human-readable rendering for stdout (plain text, no colors — agent-friendly).
2
+ function asDict(value) {
3
+ return value !== null && typeof value === "object" ? value : {};
4
+ }
5
+ function str(value) {
6
+ return typeof value === "string" && value !== "" ? value : undefined;
7
+ }
8
+ function num(value) {
9
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
10
+ }
11
+ export function formatDuration(seconds) {
12
+ const total = num(seconds);
13
+ if (total === undefined)
14
+ return undefined;
15
+ const h = Math.floor(total / 3600);
16
+ const m = Math.floor((total % 3600) / 60);
17
+ const s = Math.floor(total % 60);
18
+ const pad = (n) => String(n).padStart(2, "0");
19
+ return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
20
+ }
21
+ function formatViews(count) {
22
+ const n = num(count);
23
+ return n === undefined ? undefined : `${n.toLocaleString("en-US")} views`;
24
+ }
25
+ export function renderSearch(result) {
26
+ const videos = asDict(result).videos;
27
+ if (!Array.isArray(videos) || videos.length === 0)
28
+ return "No results.\n";
29
+ const blocks = videos.map((entry, i) => {
30
+ const v = asDict(entry);
31
+ const lines = [`${i + 1}. ${str(v.title) ?? "(untitled)"}`];
32
+ const meta = [str(v.channel), formatDuration(v.duration_s), formatViews(v.view_count), str(v.published)]
33
+ .filter((part) => part !== undefined)
34
+ .join(" | ");
35
+ if (meta)
36
+ lines.push(` ${meta}`);
37
+ if (str(v.youtube_id))
38
+ lines.push(` https://youtu.be/${v.youtube_id}`);
39
+ if (str(v.description_snippet))
40
+ lines.push(` ${v.description_snippet}`);
41
+ return lines.join("\n");
42
+ });
43
+ return blocks.join("\n\n") + "\n";
44
+ }
45
+ export function renderVideo(result) {
46
+ const v = asDict(result);
47
+ const lines = [str(v.title) ?? "(untitled)"];
48
+ const published = str(v.published_at);
49
+ const meta = [
50
+ str(v.channel),
51
+ formatDuration(v.duration_s),
52
+ formatViews(v.view_count),
53
+ published ? `published ${published.slice(0, 10)}` : undefined,
54
+ ]
55
+ .filter((part) => part !== undefined)
56
+ .join(" | ");
57
+ if (meta)
58
+ lines.push(meta);
59
+ if (str(v.youtube_id))
60
+ lines.push(`https://youtu.be/${v.youtube_id}`);
61
+ const description = str(v.description);
62
+ if (description)
63
+ lines.push("", description.trim());
64
+ if (Array.isArray(v.chapters) && v.chapters.length > 0) {
65
+ lines.push("", "Chapters:");
66
+ for (const entry of v.chapters) {
67
+ const chapter = asDict(entry);
68
+ const at = formatDuration(chapter.start_s) ?? "?";
69
+ lines.push(` [${at}] ${str(chapter.title) ?? ""}`.trimEnd());
70
+ }
71
+ }
72
+ return lines.join("\n") + "\n";
73
+ }
74
+ export function renderSummary(result) {
75
+ const sections = asDict(asDict(result).sections);
76
+ const entries = Object.entries(sections);
77
+ if (entries.length === 0)
78
+ return "No sections returned.\n";
79
+ const blocks = entries.map(([name, value]) => `## ${titleCase(name)}\n\n${renderSectionValue(value)}`);
80
+ return blocks.join("\n\n") + "\n";
81
+ }
82
+ function titleCase(name) {
83
+ return name
84
+ .split("_")
85
+ .map((word) => (word === "" ? word : word[0].toUpperCase() + word.slice(1)))
86
+ .join(" ");
87
+ }
88
+ function renderSectionValue(value) {
89
+ if (typeof value === "string")
90
+ return value.trim();
91
+ if (Array.isArray(value)) {
92
+ return value.map((item) => `- ${renderListItem(item)}`).join("\n");
93
+ }
94
+ return JSON.stringify(value, null, 2);
95
+ }
96
+ function renderListItem(item) {
97
+ if (typeof item === "string")
98
+ return item;
99
+ const o = asDict(item);
100
+ // timestamps section: {time, description}
101
+ if (str(o.time) && str(o.description))
102
+ return `[${o.time}] ${o.description}`;
103
+ // resources section: {name, url, ...}
104
+ if (str(o.name))
105
+ return str(o.url) ? `${o.name} (${o.url})` : `${o.name}`;
106
+ return JSON.stringify(item);
107
+ }
108
+ export function renderCredits(result) {
109
+ const c = asDict(result);
110
+ const lines = [
111
+ `Tier: ${str(c.tier) ?? "unknown"}`,
112
+ `Monthly allowance: ${num(c.monthly_allowance) ?? "?"} credits`,
113
+ `Used this month: ${num(c.used_this_month) ?? "?"}`,
114
+ `Remaining: ${num(c.remaining) ?? "?"}`,
115
+ ];
116
+ return lines.join("\n") + "\n";
117
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@youtubesearch/cli",
3
+ "version": "0.1.2",
4
+ "description": "Agent-optimized YouTube search, transcripts, and summaries. CLI for the YouTubeSearch API.",
5
+ "license": "MIT",
6
+ "homepage": "https://youtubesearch.dev",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/youtubesearch-dev/youtubesearch.git",
10
+ "directory": "cli"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/youtubesearch-dev/youtubesearch/issues"
14
+ },
15
+ "type": "module",
16
+ "bin": {
17
+ "youtubesearch": "dist/cli.js"
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
28
+ "scripts": {
29
+ "build": "tsc -p tsconfig.json",
30
+ "typecheck": "tsc -p tsconfig.json --noEmit",
31
+ "prepack": "npm run build"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^24",
35
+ "typescript": "^5.9"
36
+ }
37
+ }