@mkterswingman/5mghost-yonder 0.0.37 → 0.0.39

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.
Files changed (44) hide show
  1. package/dist/cli/check.js +6 -2
  2. package/dist/cli/index.js +14 -1
  3. package/dist/cli/installSkills.js +21 -12
  4. package/dist/cli/serve.js +7 -3
  5. package/dist/cli/setup.js +50 -32
  6. package/dist/cli/setupCookies.d.ts +1 -4
  7. package/dist/cli/setupCookies.js +15 -2
  8. package/dist/cli/uninstall.js +21 -18
  9. package/dist/contracts/youtubeToolContracts.d.ts +17 -0
  10. package/dist/contracts/youtubeToolContracts.js +189 -0
  11. package/dist/server.d.ts +1 -1
  12. package/dist/tools/downloads.d.ts +1 -1
  13. package/dist/tools/remote.d.ts +1 -1
  14. package/dist/tools/remote.js +32 -51
  15. package/dist/tools/subtitles/cookieSession.d.ts +22 -0
  16. package/dist/tools/subtitles/cookieSession.js +66 -0
  17. package/dist/tools/subtitles/download.d.ts +24 -0
  18. package/dist/tools/subtitles/download.js +169 -0
  19. package/dist/tools/subtitles/parse.d.ts +1 -0
  20. package/dist/tools/subtitles/parse.js +106 -0
  21. package/dist/tools/subtitles.d.ts +5 -26
  22. package/dist/tools/subtitles.js +7 -389
  23. package/dist/utils/codeBuddy.d.ts +8 -0
  24. package/dist/utils/codeBuddy.js +62 -0
  25. package/dist/utils/cookieRefresh.js +7 -0
  26. package/dist/utils/launcher.d.ts +6 -11
  27. package/dist/utils/launcher.js +11 -82
  28. package/dist/utils/workBuddy.d.ts +8 -0
  29. package/dist/utils/workBuddy.js +62 -0
  30. package/package.json +6 -1
  31. package/dist/auth/oauthFlow.d.ts +0 -9
  32. package/dist/auth/oauthFlow.js +0 -151
  33. package/dist/auth/sharedAuth.d.ts +0 -10
  34. package/dist/auth/sharedAuth.js +0 -31
  35. package/dist/auth/tokenManager.d.ts +0 -18
  36. package/dist/auth/tokenManager.js +0 -92
  37. package/dist/utils/codexInternal.d.ts +0 -9
  38. package/dist/utils/codexInternal.js +0 -60
  39. package/dist/utils/mcpRegistration.d.ts +0 -7
  40. package/dist/utils/mcpRegistration.js +0 -23
  41. package/dist/utils/openClaw.d.ts +0 -18
  42. package/dist/utils/openClaw.js +0 -82
  43. package/dist/utils/skills.d.ts +0 -16
  44. package/dist/utils/skills.js +0 -56
@@ -1,20 +1,14 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { PATHS } from "../utils/config.js";
5
5
  import { runYtDlp } from "../utils/ytdlp.js";
6
- import { hasSIDCookies, areCookiesExpired } from "../utils/cookies.js";
7
- import { tryHeadlessRefresh } from "../utils/cookieRefresh.js";
8
- import { appendDiagnosticLog, classifyYtDlpFailure } from "../utils/ytdlpFailures.js";
6
+ import { hasSIDCookies } from "../utils/cookies.js";
9
7
  import { resolveVideoInput, normalizeVideoInputs } from "../utils/videoInput.js";
8
+ import { COOKIE_EXPIRED_MESSAGE, COOKIE_INVALID_MESSAGE, RATE_LIMITED_MESSAGE, SIGN_IN_REQUIRED_MESSAGE, ensureSubtitleCookiesReady, tryRefreshSubtitleCookies, toReadableSubtitleJobError } from "./subtitles/cookieSession.js";
9
+ import { downloadSubtitle, downloadSubtitlesForLanguages } from "./subtitles/download.js";
10
+ import { vttToCsv } from "./subtitles/parse.js";
10
11
  const AUTH_REQUIRED_MSG = "❌ 未认证。请先登录:\n• OAuth: npx @mkterswingman/5mghost-yonder setup\n• PAT: 设置环境变量 YT_MCP_TOKEN 或在 https://mkterswingman.com/pat/login 生成 token";
11
- const COOKIE_SETUP_COMMAND = "yt-mcp setup-cookies";
12
- const COOKIE_MISSING_MESSAGE = `No valid YouTube cookies found.\nPlease run in your terminal: ${COOKIE_SETUP_COMMAND}`;
13
- const COOKIE_EXPIRED_MESSAGE = `YouTube cookies have expired and auto-refresh failed.\nPlease run in your terminal: ${COOKIE_SETUP_COMMAND}`;
14
- const COOKIE_INVALID_MESSAGE = `YouTube rejected the current cookies and auto-refresh failed.\nPlease run in your terminal: ${COOKIE_SETUP_COMMAND}`;
15
- const SIGN_IN_REQUIRED_MESSAGE = `YouTube requested an additional sign-in confirmation and auto-refresh failed.\nPlease run in your terminal: ${COOKIE_SETUP_COMMAND}`;
16
- const RATE_LIMITED_MESSAGE = "The current YouTube session has been rate-limited. Wait and retry later.";
17
- const COOKIE_JOB_MESSAGE = `YouTube cookies are missing, expired, or invalid.\nPlease run in your terminal: ${COOKIE_SETUP_COMMAND}`;
18
12
  function toolOk(payload) {
19
13
  return {
20
14
  structuredContent: payload,
@@ -40,382 +34,14 @@ function sleep(ms) {
40
34
  function randomSleep(min, max) {
41
35
  return sleep(Math.random() * (max - min) + min);
42
36
  }
43
- async function tryRefreshSubtitleCookies() {
44
- try {
45
- return await tryHeadlessRefresh();
46
- }
47
- catch {
48
- return false;
49
- }
50
- }
51
- function isCookieFailureText(error) {
52
- if (!error) {
53
- return false;
54
- }
55
- const normalized = error.toLowerCase();
56
- return (normalized.includes("cookies_expired") ||
57
- normalized.includes("cookie") ||
58
- normalized.includes("sign in") ||
59
- normalized.includes("login") ||
60
- normalized.includes("not a bot"));
61
- }
62
- function classifyYtDlpCookieFailure(stderr) {
63
- const kind = classifyYtDlpFailure(stderr);
64
- switch (kind) {
65
- case "COOKIES_EXPIRED":
66
- return { code: kind, message: COOKIE_EXPIRED_MESSAGE };
67
- case "COOKIES_INVALID":
68
- return { code: kind, message: COOKIE_INVALID_MESSAGE };
69
- case "SIGN_IN_REQUIRED":
70
- return { code: kind, message: SIGN_IN_REQUIRED_MESSAGE };
71
- case "RATE_LIMITED":
72
- return { code: kind, message: RATE_LIMITED_MESSAGE };
73
- default:
74
- return null;
75
- }
76
- }
77
- export function toReadableSubtitleJobError(error) {
78
- const message = error instanceof Error ? error.message : String(error);
79
- return isCookieFailureText(message) ? COOKIE_JOB_MESSAGE : message;
80
- }
81
- async function ensureSubtitleCookiesReady() {
82
- const hasSession = hasSIDCookies(PATHS.cookiesTxt);
83
- const expired = areCookiesExpired(PATHS.cookiesTxt);
84
- if (hasSession && !expired) {
85
- return { ok: true };
86
- }
87
- const refreshed = await tryRefreshSubtitleCookies();
88
- if (refreshed && hasSIDCookies(PATHS.cookiesTxt) && !areCookiesExpired(PATHS.cookiesTxt)) {
89
- return { ok: true };
90
- }
91
- return {
92
- ok: false,
93
- code: hasSession && expired ? "COOKIES_EXPIRED" : "COOKIES_MISSING",
94
- toolMessage: hasSession && expired ? COOKIE_EXPIRED_MESSAGE : COOKIE_MISSING_MESSAGE,
95
- jobMessage: appendDiagnosticLog(COOKIE_JOB_MESSAGE, undefined),
96
- };
97
- }
98
- /**
99
- * Decode common HTML entities found in YouTube auto-captions.
100
- */
101
- function decodeHtmlEntities(text) {
102
- return text
103
- .replace(/>/g, ">")
104
- .replace(/&lt;/g, "<")
105
- .replace(/&amp;/g, "&")
106
- .replace(/&quot;/g, '"')
107
- .replace(/&#39;/g, "'")
108
- .replace(/&nbsp;/g, " ");
109
- }
110
- /**
111
- * Parse a VTT timestamp line into start/end seconds + clean time strings.
112
- * Input: "00:00:02.159 --> 00:00:03.590 align:start position:0%"
113
- * Returns: { startStr, endStr, startSec, endSec } or null if unparseable.
114
- */
115
- function parseTimestamp(line) {
116
- // Strip positioning metadata (align:start position:0% etc.)
117
- const match = line.match(/(\d{1,2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{1,2}:\d{2}:\d{2}\.\d{3})/);
118
- if (!match)
119
- return null;
120
- const toSec = (t) => {
121
- const parts = t.split(":");
122
- return (Number(parts[0]) * 3600 + Number(parts[1]) * 60 + Number(parts[2]));
123
- };
124
- return {
125
- startStr: match[1],
126
- endStr: match[2],
127
- startSec: toSec(match[1]),
128
- endSec: toSec(match[2]),
129
- };
130
- }
131
- /**
132
- * Escape a value for CSV (RFC 4180).
133
- */
134
- function csvEscapeField(value) {
135
- if (/[",\n\r]/.test(value)) {
136
- return `"${value.replace(/"/g, '""')}"`;
137
- }
138
- return value;
139
- }
140
- /**
141
- * Convert VTT subtitle content to clean, human-readable CSV.
142
- *
143
- * YouTube auto-captions use a "rolling" VTT format where each cue has two
144
- * lines: the first line repeats the previous cue's text, and the second line
145
- * contains new words (marked with <c> tags for word-level timing). This
146
- * function detects and handles this pattern:
147
- *
148
- * 1. Detects auto-caption format (presence of <c> word-timing tags)
149
- * 2. For auto-captions: extracts only the NEW text from each cue's second
150
- * line, skips transition cues, and concatenates into clean sentences
151
- * 3. For manual subtitles: passes through cleanly with no data loss
152
- * 4. Outputs: start_time, end_time, text
153
- */
154
- export function vttToCsv(vtt) {
155
- const lines = vtt.split("\n");
156
- const isAutoCaption = /<\d{2}:\d{2}:\d{2}\.\d{3}><c>/.test(vtt);
157
- const rawCues = [];
158
- let currentTs = null;
159
- let currentTextLines = [];
160
- for (const line of lines) {
161
- const trimmed = line.trim();
162
- if (trimmed.includes(" --> ")) {
163
- // Flush previous cue
164
- if (currentTs && currentTextLines.length > 0) {
165
- let text;
166
- if (isAutoCaption && currentTextLines.length >= 2) {
167
- // Auto-caption: line 1 = repeated text, line 2 = new text with <c> tags
168
- // Only take line 2 (new content)
169
- text = decodeHtmlEntities(currentTextLines[currentTextLines.length - 1]
170
- .replace(/<[^>]*>/g, "")
171
- .trim());
172
- }
173
- else {
174
- // Manual subtitle or single-line cue: take all lines
175
- text = decodeHtmlEntities(currentTextLines
176
- .map((l) => l.replace(/<[^>]*>/g, "").trim())
177
- .filter(Boolean)
178
- .join(" "));
179
- }
180
- if (text) {
181
- rawCues.push({ ...currentTs, text });
182
- }
183
- }
184
- currentTs = parseTimestamp(trimmed);
185
- currentTextLines = [];
186
- }
187
- else if (trimmed &&
188
- !trimmed.startsWith("WEBVTT") &&
189
- !trimmed.startsWith("Kind:") &&
190
- !trimmed.startsWith("Language:") &&
191
- !/^\d+$/.test(trimmed)) {
192
- currentTextLines.push(trimmed);
193
- }
194
- }
195
- // Flush last
196
- if (currentTs && currentTextLines.length > 0) {
197
- let text;
198
- if (isAutoCaption && currentTextLines.length >= 2) {
199
- text = decodeHtmlEntities(currentTextLines[currentTextLines.length - 1]
200
- .replace(/<[^>]*>/g, "")
201
- .trim());
202
- }
203
- else {
204
- text = decodeHtmlEntities(currentTextLines
205
- .map((l) => l.replace(/<[^>]*>/g, "").trim())
206
- .filter(Boolean)
207
- .join(" "));
208
- }
209
- if (text) {
210
- rawCues.push({ ...currentTs, text });
211
- }
212
- }
213
- if (rawCues.length === 0) {
214
- return "start_time,end_time,text\n";
215
- }
216
- // ── Step 2: Deduplicate ───────────────────────────────────────
217
- const deduped = [];
218
- for (let i = 0; i < rawCues.length; i++) {
219
- const cur = rawCues[i];
220
- // Skip tiny transition cues (duration < 50ms)
221
- const duration = cur.endSec - cur.startSec;
222
- if (duration < 0.05)
223
- continue;
224
- // Merge with previous if same text
225
- if (deduped.length > 0 && deduped[deduped.length - 1].text === cur.text) {
226
- deduped[deduped.length - 1].endSec = cur.endSec;
227
- deduped[deduped.length - 1].endStr = cur.endStr;
228
- continue;
229
- }
230
- deduped.push({ ...cur });
231
- }
232
- // ── Step 3: Build CSV ─────────────────────────────────────────
233
- const csvRows = ["start_time,end_time,text"];
234
- for (const cue of deduped) {
235
- csvRows.push(`${cue.startStr},${cue.endStr},${csvEscapeField(cue.text)}`);
236
- }
237
- return csvRows.join("\n") + "\n";
238
- }
239
- function todayDateStr() {
240
- const d = new Date();
241
- const yyyy = d.getFullYear();
242
- const mm = String(d.getMonth() + 1).padStart(2, "0");
243
- const dd = String(d.getDate()).padStart(2, "0");
244
- return `${yyyy}-${mm}-${dd}`;
245
- }
246
- async function downloadSubtitle(videoId, lang, format, options = {}) {
247
- const outputDir = options.outputDir ?? PATHS.subtitlesDir;
248
- mkdirSync(outputDir, { recursive: true });
249
- if (options.targetFile) {
250
- mkdirSync(dirname(options.targetFile), { recursive: true });
251
- }
252
- const outTemplate = join(outputDir, options.outputStem ?? `${todayDateStr()}_${videoId}_${lang}`);
253
- // CSV is not a yt-dlp native format — download as VTT then convert
254
- const dlFormat = format === "csv" ? "vtt" : format;
255
- const result = await runYtDlp([
256
- "--skip-download",
257
- "-f", "mhtml",
258
- "--write-sub",
259
- "--write-auto-sub",
260
- "--sub-langs",
261
- lang,
262
- "--sub-format",
263
- dlFormat,
264
- "--output",
265
- outTemplate,
266
- options.sourceUrl ?? `https://www.youtube.com/watch?v=${videoId}`,
267
- ]);
268
- const cookieFailure = result.exitCode !== 0 ? classifyYtDlpCookieFailure(result.stderr) : null;
269
- if (cookieFailure) {
270
- return {
271
- ok: false,
272
- cookieFailureCode: cookieFailure.code,
273
- diagnosticLogPath: result.failureLogPath,
274
- error: appendDiagnosticLog(cookieFailure.message, result.failureLogPath),
275
- };
276
- }
277
- if (result.exitCode !== 0) {
278
- return {
279
- ok: false,
280
- diagnosticLogPath: result.failureLogPath,
281
- error: appendDiagnosticLog(result.stderr.slice(0, 500) || `yt-dlp exited with ${result.exitCode}`, result.failureLogPath),
282
- };
283
- }
284
- // Find the output file - yt-dlp appends lang and format extension.
285
- // For CSV: yt-dlp downloads as VTT first, so only search for VTT (not stale .csv from previous runs).
286
- const searchFormat = format === "csv" ? "vtt" : format;
287
- const possibleExts = [`${lang}.${searchFormat}`, `${lang}.vtt`, `${lang}.srt`, `${lang}.ttml`, `${lang}.srv3`];
288
- let foundFile;
289
- for (const ext of possibleExts) {
290
- const candidate = `${outTemplate}.${ext}`;
291
- if (existsSync(candidate)) {
292
- foundFile = candidate;
293
- break;
294
- }
295
- }
296
- if (!foundFile) {
297
- // Try auto-generated subtitles which may have slightly different naming
298
- const dir = outputDir;
299
- try {
300
- const files = readdirSync(dir);
301
- const prefix = options.outputStem ?? `${todayDateStr()}_${videoId}_${lang}`;
302
- const match = files.find((f) => f.startsWith(prefix));
303
- if (match) {
304
- foundFile = join(dir, match);
305
- }
306
- }
307
- catch {
308
- // ignore
309
- }
310
- }
311
- if (!foundFile) {
312
- return {
313
- ok: false,
314
- error: `No subtitle file found for language '${lang}'`,
315
- };
316
- }
317
- // If CSV format requested, convert VTT to CSV (timestamp, text)
318
- if (format === "csv") {
319
- const vttPath = foundFile;
320
- const vttContent = readFileSync(foundFile, "utf8");
321
- const csvContent = vttToCsv(vttContent);
322
- const csvPath = foundFile.replace(/\.vtt$/, ".csv");
323
- writeFileSync(csvPath, csvContent, "utf8");
324
- foundFile = csvPath;
325
- if (vttPath !== csvPath) {
326
- try {
327
- unlinkSync(vttPath);
328
- }
329
- catch {
330
- // ignore
331
- }
332
- }
333
- }
334
- if (options.targetFile && foundFile !== options.targetFile) {
335
- renameSync(foundFile, options.targetFile);
336
- foundFile = options.targetFile;
337
- }
338
- const stat = statSync(foundFile);
339
- if (stat.size <= 100 * 1024) {
340
- const text = readFileSync(foundFile, "utf8");
341
- return { ok: true, text, filePath: foundFile };
342
- }
343
- return { ok: true, filePath: foundFile };
344
- }
345
- export async function downloadSubtitlesForLanguages(input) {
346
- const cookieCheck = await ensureSubtitleCookiesReady();
347
- if (!cookieCheck.ok) {
348
- throw new Error(cookieCheck.jobMessage);
349
- }
350
- const filesByFormat = {};
351
- const total = input.formats.length * input.languages.length;
352
- let completed = 0;
353
- for (const format of input.formats) {
354
- filesByFormat[format] = [];
355
- for (const lang of input.languages) {
356
- const targetFile = join(input.subtitlesDir, `${lang}.${format}`);
357
- let result = await downloadSubtitle(input.videoId, lang, format, {
358
- sourceUrl: input.sourceUrl,
359
- outputDir: input.subtitlesDir,
360
- outputStem: `${lang}`,
361
- targetFile,
362
- });
363
- if (result.cookieFailureCode && result.cookieFailureCode !== "RATE_LIMITED") {
364
- const refreshed = await tryRefreshSubtitleCookies();
365
- if (refreshed) {
366
- result = await downloadSubtitle(input.videoId, lang, format, {
367
- sourceUrl: input.sourceUrl,
368
- outputDir: input.subtitlesDir,
369
- outputStem: `${lang}`,
370
- targetFile,
371
- });
372
- }
373
- }
374
- if (!result.ok || !result.filePath) {
375
- if (isCookieFailureText(result.error)) {
376
- throw new Error(COOKIE_JOB_MESSAGE);
377
- }
378
- if (input.skipMissingLanguages && isUnavailableSubtitleError(result.error)) {
379
- completed += 1;
380
- input.onProgress?.(completed, total);
381
- continue;
382
- }
383
- throw new Error(result.error ?? `Failed to download ${format} subtitle for ${lang}`);
384
- }
385
- filesByFormat[format].push(result.filePath);
386
- completed += 1;
387
- input.onProgress?.(completed, total);
388
- }
389
- }
390
- const hasAnySubtitleFiles = Object.values(filesByFormat).some((files) => files.length > 0);
391
- if (!hasAnySubtitleFiles) {
392
- throw new Error(`No subtitles found for requested languages: ${input.languages.join(", ")}`);
393
- }
394
- return filesByFormat;
395
- }
396
- function isUnavailableSubtitleError(error) {
397
- if (!error) {
398
- return false;
399
- }
400
- const normalized = error.toLowerCase();
401
- return (normalized.includes("no subtitle file found") ||
402
- normalized.includes("no subtitles") ||
403
- normalized.includes("subtitle is not available") ||
404
- normalized.includes("requested subtitles are not available"));
405
- }
37
+ export { vttToCsv, downloadSubtitlesForLanguages, toReadableSubtitleJobError };
406
38
  export function registerSubtitleTools(server, config, tokenManager) {
407
- /**
408
- * Auth guard — every tool call must pass authentication.
409
- * Both OAuth (JWT) and PAT modes are supported.
410
- * Returns error message if not authenticated.
411
- */
412
39
  async function requireAuth() {
413
40
  const token = await tokenManager.getValidToken();
414
41
  if (!token)
415
42
  return AUTH_REQUIRED_MSG;
416
43
  return null;
417
44
  }
418
- // ── get_subtitles ──
419
45
  server.registerTool("get_subtitles", {
420
46
  description: "Download subtitles for a YouTube video. Accepts video ID or any YouTube URL (watch, shorts, youtu.be). Each language is fetched separately. Supports CSV output (timestamp + text columns). If languages is omitted, defaults to English + Simplified Chinese (unavailable languages are silently skipped).",
421
47
  inputSchema: {
@@ -447,7 +73,6 @@ export function registerSubtitleTools(server, config, tokenManager) {
447
73
  if (dl.cookieFailureCode) {
448
74
  const refreshed = await tryRefreshSubtitleCookies();
449
75
  if (refreshed) {
450
- // Retry the download with fresh cookies
451
76
  const retry = await downloadSubtitle(videoId, lang, fmt);
452
77
  if (retry.ok) {
453
78
  if (retry.text) {
@@ -458,7 +83,6 @@ export function registerSubtitleTools(server, config, tokenManager) {
458
83
  }
459
84
  continue;
460
85
  }
461
- // Retry also failed with expired cookies — give up
462
86
  }
463
87
  return toolErr(dl.cookieFailureCode, dl.error ??
464
88
  (dl.cookieFailureCode === "COOKIES_EXPIRED"
@@ -470,7 +94,6 @@ export function registerSubtitleTools(server, config, tokenManager) {
470
94
  : COOKIE_INVALID_MESSAGE));
471
95
  }
472
96
  if (!dl.ok) {
473
- // When using default languages, silently skip unavailable ones
474
97
  if (!usingDefaults) {
475
98
  results.push({
476
99
  language: lang,
@@ -505,7 +128,6 @@ export function registerSubtitleTools(server, config, tokenManager) {
505
128
  results,
506
129
  });
507
130
  });
508
- // ── batch_get_subtitles ──
509
131
  server.registerTool("batch_get_subtitles", {
510
132
  description: "Download subtitles for multiple YouTube videos. Max 10 per batch. Accepts video IDs or URLs (can mix).",
511
133
  inputSchema: {
@@ -560,7 +182,6 @@ export function registerSubtitleTools(server, config, tokenManager) {
560
182
  if (cookieFailureCode) {
561
183
  const refreshed = await tryRefreshSubtitleCookies();
562
184
  if (refreshed) {
563
- // Retry this video's subtitles
564
185
  let retrySuccess = false;
565
186
  for (const lang of langs) {
566
187
  const retry = await downloadSubtitle(videoId, lang, fmt);
@@ -608,7 +229,6 @@ export function registerSubtitleTools(server, config, tokenManager) {
608
229
  results,
609
230
  });
610
231
  });
611
- // ── list_available_subtitles ──
612
232
  server.registerTool("list_available_subtitles", {
613
233
  description: "List available subtitle tracks for a YouTube video. Accepts video ID or URL.",
614
234
  inputSchema: {
@@ -664,7 +284,6 @@ export function registerSubtitleTools(server, config, tokenManager) {
664
284
  translatable: automatic,
665
285
  });
666
286
  });
667
- // ── validate_cookies ──
668
287
  server.registerTool("validate_cookies", {
669
288
  description: "Validate current YouTube cookies by testing a subtitle download.",
670
289
  inputSchema: {},
@@ -684,7 +303,6 @@ export function registerSubtitleTools(server, config, tokenManager) {
684
303
  error: "cookies.txt does not contain YouTube SID cookies",
685
304
  });
686
305
  }
687
- // Test with a known public video (Rick Astley - Never Gonna Give You Up)
688
306
  const result = await runYtDlp([
689
307
  "--skip-download",
690
308
  "-f", "mhtml",
@@ -0,0 +1,8 @@
1
+ export declare function getCodeBuddyConfigPath(homeDir?: string): string;
2
+ export declare function getCodeBuddySkillsDir(homeDir?: string): string;
3
+ export declare function isCodeBuddyInstallLikelyInstalled(configPath?: string): boolean;
4
+ export declare function writeCodeBuddyConfig(serverName: string, launcherCommand: {
5
+ command: string;
6
+ args: string[];
7
+ }, configPath?: string): "created" | "updated";
8
+ export declare function removeCodeBuddyConfig(serverName: string, configPath?: string): "removed" | "missing";
@@ -0,0 +1,62 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ export function getCodeBuddyConfigPath(homeDir = homedir()) {
5
+ return join(homeDir, ".codebuddy", ".mcp.json");
6
+ }
7
+ export function getCodeBuddySkillsDir(homeDir = homedir()) {
8
+ return join(homeDir, ".codebuddy", "skills");
9
+ }
10
+ function parseConfig(raw) {
11
+ const parsed = JSON.parse(raw);
12
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
13
+ return parsed;
14
+ }
15
+ throw new Error("CodeBuddy config must be a JSON object");
16
+ }
17
+ export function isCodeBuddyInstallLikelyInstalled(configPath = getCodeBuddyConfigPath()) {
18
+ return existsSync(configPath) || existsSync(dirname(configPath));
19
+ }
20
+ export function writeCodeBuddyConfig(serverName, launcherCommand, configPath = getCodeBuddyConfigPath()) {
21
+ const existingText = existsSync(configPath) ? readFileSync(configPath, "utf8") : null;
22
+ const created = existingText === null;
23
+ const config = existingText ? parseConfig(existingText) : {};
24
+ const mcpServers = config.mcpServers && typeof config.mcpServers === "object"
25
+ ? { ...config.mcpServers }
26
+ : {};
27
+ mcpServers[serverName] = {
28
+ command: launcherCommand.command,
29
+ args: [...launcherCommand.args],
30
+ };
31
+ const nextConfig = {
32
+ ...config,
33
+ mcpServers,
34
+ };
35
+ mkdirSync(dirname(configPath), { recursive: true });
36
+ writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf8");
37
+ return created ? "created" : "updated";
38
+ }
39
+ export function removeCodeBuddyConfig(serverName, configPath = getCodeBuddyConfigPath()) {
40
+ if (!existsSync(configPath)) {
41
+ return "missing";
42
+ }
43
+ const config = parseConfig(readFileSync(configPath, "utf8"));
44
+ if (!config.mcpServers || typeof config.mcpServers !== "object" || !(serverName in config.mcpServers)) {
45
+ return "missing";
46
+ }
47
+ const nextMcpServers = { ...config.mcpServers };
48
+ delete nextMcpServers[serverName];
49
+ const nextConfig = { ...config };
50
+ if (Object.keys(nextMcpServers).length === 0) {
51
+ delete nextConfig.mcpServers;
52
+ }
53
+ else {
54
+ nextConfig.mcpServers = nextMcpServers;
55
+ }
56
+ if (Object.keys(nextConfig).length === 0) {
57
+ rmSync(configPath, { force: true });
58
+ return "removed";
59
+ }
60
+ writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf8");
61
+ return "removed";
62
+ }
@@ -13,6 +13,9 @@ import { PATHS, ensureConfigDir } from "./config.js";
13
13
  import { detectBrowserChannel, hasYouTubeSession, saveCookiesToDisk, } from "../cli/setupCookies.js";
14
14
  import { hasSIDCookies } from "./cookies.js";
15
15
  const HEADLESS_TIMEOUT_MS = 30_000;
16
+ function hasInteractiveTerminal() {
17
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
18
+ }
16
19
  /**
17
20
  * Attempt to refresh YouTube cookies through the dedicated two-tier fallback chain.
18
21
  *
@@ -26,6 +29,10 @@ export async function tryHeadlessRefresh() {
26
29
  if (tier1)
27
30
  return true;
28
31
  }
32
+ // Why: MCP stdio calls and tests run without a TTY; opening manual browser login would block until timeout.
33
+ if (!hasInteractiveTerminal()) {
34
+ return false;
35
+ }
29
36
  // Tier 2: Open headed browser for manual login
30
37
  return tryHeadedManualLogin();
31
38
  }
@@ -1,12 +1,7 @@
1
- export interface LauncherSourceOptions {
2
- packageSpec?: string;
3
- npmCacheDir?: string;
4
- }
5
- export interface LauncherCommand {
6
- command: string;
7
- args: string[];
8
- }
1
+ import { isRepairableNpxFailure } from "@mkterswingman/5mghost-shared-client/launcher";
2
+ import type { LauncherCommand, LauncherSourceOptions } from "@mkterswingman/5mghost-shared-client/launcher";
3
+ export type { LauncherCommand, LauncherSourceOptions };
4
+ export { isRepairableNpxFailure };
9
5
  export declare function buildLauncherCommand(launcherPath?: string): LauncherCommand;
10
- export declare function isRepairableNpxFailure(stderr: string): boolean;
11
- export declare function buildLauncherSource(options?: LauncherSourceOptions): string;
12
- export declare function writeLauncherFile(options?: LauncherSourceOptions): string;
6
+ export declare function buildLauncherSource(options?: Partial<LauncherSourceOptions>): string;
7
+ export declare function writeLauncherFile(options?: Partial<LauncherSourceOptions>): string;