@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 +51 -0
- package/dist/api.js +66 -0
- package/dist/cli.js +372 -0
- package/dist/config.js +56 -0
- package/dist/render.js +117 -0
- package/package.json +37 -0
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
|
+
}
|