atris 3.53.0 → 3.55.0
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/FOR_AGENTS.md +7 -0
- package/README.md +1 -1
- package/atris/policies/ANTISLOP.md +2 -1
- package/atris/skills/copy-editor/SKILL.md +2 -1
- package/atris/skills/design/SKILL.md +1 -1
- package/atris/skills/x-search/SKILL.md +24 -22
- package/atris/skills/youtube/SKILL.md +82 -33
- package/atris/team/_template/SOUL.md +40 -0
- package/atris/team/validator/MEMBER.md +1 -2
- package/bin/atris +21 -0
- package/bin/atris.js +563 -373
- package/commands/api-key.js +170 -0
- package/commands/auth.js +453 -43
- package/commands/autoland.js +3 -1
- package/commands/autopilot-front.js +58 -11
- package/commands/avail.js +5 -4
- package/commands/balance.js +55 -0
- package/commands/bench.js +29 -1
- package/commands/brain.js +63 -12
- package/commands/brainstorm.js +18 -0
- package/commands/business.js +149 -1
- package/commands/chat-scan.js +17 -4
- package/commands/close.js +5 -1
- package/commands/doctor.js +174 -0
- package/commands/dream.js +7 -0
- package/commands/drill.js +34 -2
- package/commands/engine.js +47 -35
- package/commands/errors.js +58 -8
- package/commands/experiments.js +14 -0
- package/commands/feedback.js +61 -2
- package/commands/fleet-report.js +27 -7
- package/commands/fleet.js +98 -11
- package/commands/founder.js +60 -21
- package/commands/human-missions.js +79 -4
- package/commands/improve.js +54 -4
- package/commands/init.js +68 -92
- package/commands/install.js +40 -0
- package/commands/integrations.js +399 -30
- package/commands/interview.js +8 -0
- package/commands/land.js +48 -17
- package/commands/learn.js +29 -4
- package/commands/log.js +51 -0
- package/commands/member.js +68 -10
- package/commands/mission.js +287 -50
- package/commands/now.js +15 -3
- package/commands/orb.js +97 -2
- package/commands/pack.js +83 -9
- package/commands/playbook.js +381 -0
- package/commands/plugin.js +16 -0
- package/commands/radar.js +31 -7
- package/commands/recap.js +128 -21
- package/commands/release.js +102 -2
- package/commands/review.js +5 -10
- package/commands/revisions.js +55 -0
- package/commands/run-front.js +12 -4
- package/commands/scout.js +12 -1
- package/commands/search.js +123 -28
- package/commands/sign.js +2 -2
- package/commands/signup.js +12 -3
- package/commands/site-deploy.js +17 -4
- package/commands/site.js +2 -2
- package/commands/skill-eval.js +289 -0
- package/commands/skill.js +56 -6
- package/commands/social.js +603 -0
- package/commands/spaceship.js +69 -6
- package/commands/stream.js +8 -1
- package/commands/study.js +32 -19
- package/commands/sync.js +28 -22
- package/commands/task.js +418 -60
- package/commands/teach.js +72 -63
- package/commands/terminal.js +25 -9
- package/commands/topup.js +88 -0
- package/commands/truth.js +52 -6
- package/commands/usage.js +91 -0
- package/commands/verify.js +3 -2
- package/commands/version.js +3 -1
- package/commands/voice.js +5 -1
- package/commands/who.js +69 -11
- package/commands/wiki.js +7 -4
- package/commands/wish.js +76 -5
- package/commands/workflow.js +327 -145
- package/commands/worktree.js +1 -0
- package/commands/write.js +5 -0
- package/commands/x-search.js +340 -0
- package/commands/xp.js +5 -1
- package/commands/youtube.js +551 -25
- package/lib/account-bound.js +47 -0
- package/lib/auto-accept-certified.js +8 -1
- package/lib/auto-lessons.js +186 -0
- package/lib/autoland.js +4 -2
- package/lib/chat-log-scan.js +7 -4
- package/lib/cli-json.js +77 -0
- package/lib/cli-scope.js +26 -0
- package/lib/codex-flight.js +1 -0
- package/lib/conductor-artifacts.js +1 -1
- package/lib/context-gatherer.js +11 -0
- package/lib/developer-api.js +116 -0
- package/lib/feedback-triage.js +274 -0
- package/lib/first-minute.js +398 -0
- package/lib/fleet.js +115 -34
- package/lib/functional-owner.js +22 -0
- package/lib/known-commands.js +31 -5
- package/lib/member-scaffold.js +197 -0
- package/lib/mission-root.js +4 -2
- package/lib/noninteractive.js +88 -0
- package/lib/policy-lessons.js +4 -1
- package/lib/revision-metric.js +279 -0
- package/lib/scratch-root.js +48 -0
- package/lib/skill-eval-gate.js +249 -0
- package/lib/task-db.js +4 -0
- package/lib/task-proof.js +69 -8
- package/lib/verifier-quality.js +69 -0
- package/lib/wish-audit.js +2 -2
- package/lib/wish-delegate.js +10 -1
- package/lib/workspace-scaffold.js +270 -0
- package/package.json +3 -2
- package/scripts/det/checklist-score.js +191 -0
- package/scripts/det/ytsearch +31 -0
- package/utils/update-check.js +15 -0
package/commands/youtube.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
const { apiRequestJson } = require('../utils/api');
|
|
2
|
-
const {
|
|
2
|
+
const { ensureBilledCommandAuth } = require('./auth');
|
|
3
3
|
const { spawnSync } = require('child_process');
|
|
4
4
|
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
5
6
|
const path = require('path');
|
|
6
7
|
const https = require('https');
|
|
7
8
|
|
|
@@ -25,7 +26,9 @@ const ALLOWED_CAPTION_HOST_SUFFIXES = [
|
|
|
25
26
|
|
|
26
27
|
function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
|
|
27
28
|
output('');
|
|
28
|
-
output(`Usage: ${commandName}
|
|
29
|
+
output(`Usage: ${commandName} search "<query>" [--limit N] [--json]`);
|
|
30
|
+
output(` ${commandName} search --paid "<query>" [--limit N] [--json]`);
|
|
31
|
+
output(` ${commandName} notes <youtube-url> [youtube-url-or-playlist...] [engine]`);
|
|
29
32
|
output(` ${commandName} process <youtube-url> [options]`);
|
|
30
33
|
output(` ${commandName} digest [--days N]`);
|
|
31
34
|
output(` ${commandName} watch add <channel-url-or-@handle>`);
|
|
@@ -34,6 +37,8 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
|
|
|
34
37
|
output(` ${commandName} watch tick`);
|
|
35
38
|
output(` ${commandName} <youtube-url> [options]`);
|
|
36
39
|
output('');
|
|
40
|
+
output('search = free local discovery (ytsearch / yt-dlp), returns youtu.be links');
|
|
41
|
+
output('search --paid = 5 credits, watch permalinks + titles from Atris');
|
|
37
42
|
output('notes = free local notes for one url, several urls, or a playlist');
|
|
38
43
|
output('process = 5 credits cloud knowledge');
|
|
39
44
|
output('digest = one decision page from this week\'s video briefs');
|
|
@@ -42,6 +47,8 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
|
|
|
42
47
|
output('Falls back to cloud video processing when local captions are unavailable.');
|
|
43
48
|
output('');
|
|
44
49
|
output('Options:');
|
|
50
|
+
output(' --limit <n> Max search results (default: 5)');
|
|
51
|
+
output(' --paid Bill 5 credits for watch permalinks (search only)');
|
|
45
52
|
output(' --query, -q <text> Focus question for the analysis');
|
|
46
53
|
output(' --agent <id> Agent id to store knowledge against');
|
|
47
54
|
output(' --store Save as agent knowledge (requires --agent)');
|
|
@@ -53,6 +60,9 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
|
|
|
53
60
|
output(' metadata -> timestamped outline -> claims -> examples -> takeaways -> Atris implications -> next actions');
|
|
54
61
|
output('');
|
|
55
62
|
output('Examples:');
|
|
63
|
+
output(` ${commandName} search "MCP agents 2026"`);
|
|
64
|
+
output(` ${commandName} search "MCP agents" --limit 10`);
|
|
65
|
+
output(` ${commandName} search --paid "MCP agents 2026"`);
|
|
56
66
|
output(` ${commandName} notes https://www.youtube.com/watch?v=VIDEO_ID`);
|
|
57
67
|
output(` ${commandName} notes https://www.youtube.com/watch?v=VIDEO_ID https://youtu.be/OTHER_ID`);
|
|
58
68
|
output(` ${commandName} notes https://www.youtube.com/playlist?list=PLAYLIST_ID`);
|
|
@@ -384,14 +394,36 @@ async function extractLocalTranscript(youtubeUrl, deps = {}) {
|
|
|
384
394
|
|
|
385
395
|
async function processYoutube(options, deps = {}) {
|
|
386
396
|
const apiFn = deps.apiRequestJson || apiRequestJson;
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
const detail = ensured?.detail || ensured?.error;
|
|
392
|
-
throw new Error(detail ? `Authentication failed: ${detail}. Run "atris login".` : 'Not logged in. Run "atris login".');
|
|
397
|
+
const ensureBilled = deps.ensureBilledCommandAuth || ensureBilledCommandAuth;
|
|
398
|
+
let auth = await ensureBilled('youtube', deps);
|
|
399
|
+
if (!auth?.ok || !auth.token) {
|
|
400
|
+
throw new Error(auth?.error || 'not signed in. run atris login first.');
|
|
393
401
|
}
|
|
394
402
|
|
|
403
|
+
const requestYoutube = async (body) => {
|
|
404
|
+
let result = await apiFn('/agent/process_youtube', {
|
|
405
|
+
method: 'POST',
|
|
406
|
+
token: auth.token,
|
|
407
|
+
timeoutMs: options.timeoutMs,
|
|
408
|
+
retries: 0,
|
|
409
|
+
body,
|
|
410
|
+
});
|
|
411
|
+
if (!result.ok && result.status === 401 && !auth.minted) {
|
|
412
|
+
const remint = await ensureBilled('youtube', { ...deps, forceMint: true });
|
|
413
|
+
if (remint?.ok && remint.token) {
|
|
414
|
+
auth = remint;
|
|
415
|
+
result = await apiFn('/agent/process_youtube', {
|
|
416
|
+
method: 'POST',
|
|
417
|
+
token: auth.token,
|
|
418
|
+
timeoutMs: options.timeoutMs,
|
|
419
|
+
retries: 0,
|
|
420
|
+
body,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return result;
|
|
425
|
+
};
|
|
426
|
+
|
|
395
427
|
const localExtractor = deps.extractLocalTranscript || extractLocalTranscript;
|
|
396
428
|
let localTranscript = null;
|
|
397
429
|
try {
|
|
@@ -401,13 +433,9 @@ async function processYoutube(options, deps = {}) {
|
|
|
401
433
|
}
|
|
402
434
|
|
|
403
435
|
if (localTranscript?.transcriptText) {
|
|
404
|
-
const transcriptResult = await
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
timeoutMs: options.timeoutMs,
|
|
408
|
-
retries: 0,
|
|
409
|
-
body: buildYoutubePayload({ ...options, localTranscript, cacheTranscript: false }),
|
|
410
|
-
});
|
|
436
|
+
const transcriptResult = await requestYoutube(
|
|
437
|
+
buildYoutubePayload({ ...options, localTranscript, cacheTranscript: false }),
|
|
438
|
+
);
|
|
411
439
|
|
|
412
440
|
if (transcriptResult.ok) {
|
|
413
441
|
return transcriptResult.data;
|
|
@@ -418,13 +446,7 @@ async function processYoutube(options, deps = {}) {
|
|
|
418
446
|
}
|
|
419
447
|
}
|
|
420
448
|
|
|
421
|
-
const result = await
|
|
422
|
-
method: 'POST',
|
|
423
|
-
token: creds.token,
|
|
424
|
-
timeoutMs: options.timeoutMs,
|
|
425
|
-
retries: 0,
|
|
426
|
-
body: buildYoutubePayload(options),
|
|
427
|
-
});
|
|
449
|
+
const result = await requestYoutube(buildYoutubePayload(options));
|
|
428
450
|
|
|
429
451
|
if (!result.ok) {
|
|
430
452
|
throw youtubeFailureError(result);
|
|
@@ -1226,8 +1248,500 @@ function runYoutubeNotes(args = [], deps = {}) {
|
|
|
1226
1248
|
return runYoutubeNotesBatch(parsed, deps);
|
|
1227
1249
|
}
|
|
1228
1250
|
|
|
1251
|
+
const DEFAULT_SEARCH_LIMIT = 5;
|
|
1252
|
+
const PAID_SEARCH_TIMEOUT_MS = 120000;
|
|
1253
|
+
const PAID_SEARCH_COST_HINT = '5 credits per search';
|
|
1254
|
+
const YTSEARCH_USAGE = 'usage: atris youtube search "<query>" [--limit N] [--json]';
|
|
1255
|
+
const SEARCH_PRINT_FORMAT = '%(title)s | %(channel)s | %(duration_string)s | %(view_count)s | %(upload_date)s | https://youtu.be/%(id)s';
|
|
1256
|
+
const LOCAL_SEARCH_RATE_LIMIT_BACKOFF_MS = 1000;
|
|
1257
|
+
const LOCAL_SEARCH_RATE_LIMIT_MESSAGE =
|
|
1258
|
+
'youtube rate-limited local search. do not use --paid as a fallback; retry later.';
|
|
1259
|
+
const LOCAL_SEARCH_RATE_LIMIT_RE = /429|too many requests|confirm you['\u2019]re not a bot/i;
|
|
1260
|
+
const LOCAL_SEARCH_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
1261
|
+
const LOCAL_SEARCH_CACHE_FILE = 'youtube-search-cache.json';
|
|
1262
|
+
const LOCAL_SEARCH_CACHE_NOTE =
|
|
1263
|
+
'cached because youtube rate-limited local search.';
|
|
1264
|
+
|
|
1265
|
+
function showYoutubeSearchHelp(output = console.log, commandName = 'atris youtube') {
|
|
1266
|
+
output('');
|
|
1267
|
+
output(`Usage: ${commandName} search "<query>" [--limit N] [--json]`);
|
|
1268
|
+
output(` ${commandName} search --paid "<query>" [--limit N] [--json]`);
|
|
1269
|
+
output('');
|
|
1270
|
+
output('Free local discovery. Uses ytsearch on PATH when present, else the');
|
|
1271
|
+
output('bundled scripts/det/ytsearch, else yt-dlp ytsearchN with the same print contract.');
|
|
1272
|
+
output('Does not bill credits. Process stays the 5-credit step after you pick a URL.');
|
|
1273
|
+
output('');
|
|
1274
|
+
output(`--paid buys watch permalinks from Atris (${PAID_SEARCH_COST_HINT}).`);
|
|
1275
|
+
output('Requires login. Same auth path as atris youtube process.');
|
|
1276
|
+
output('Empty or failed paid search refunds the credits.');
|
|
1277
|
+
output('');
|
|
1278
|
+
output('Options:');
|
|
1279
|
+
output(` --limit <n> Max results (default: ${DEFAULT_SEARCH_LIMIT})`);
|
|
1280
|
+
output(' --paid Bill credits for watch permalinks + titles');
|
|
1281
|
+
output(' --json Print JSON rows instead of pipe lines');
|
|
1282
|
+
output(' -h, --help This help');
|
|
1283
|
+
output('');
|
|
1284
|
+
output('Each free line:');
|
|
1285
|
+
output(' title | channel | duration | views | upload_date | https://youtu.be/ID');
|
|
1286
|
+
output('');
|
|
1287
|
+
output('Each paid line:');
|
|
1288
|
+
output(' title | https://www.youtube.com/watch?v=ID');
|
|
1289
|
+
output('');
|
|
1290
|
+
output('Examples:');
|
|
1291
|
+
output(` ${commandName} search "MCP agents 2026"`);
|
|
1292
|
+
output(` ${commandName} search "MCP agents" --limit 10`);
|
|
1293
|
+
output(` ${commandName} search "MCP agents" --json`);
|
|
1294
|
+
output(` ${commandName} search --paid "MCP agents 2026"`);
|
|
1295
|
+
output(` ${commandName} search --paid "MCP agents" --limit 10`);
|
|
1296
|
+
output('');
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function parseSearchArgs(argv = []) {
|
|
1300
|
+
const args = [...argv];
|
|
1301
|
+
const options = {
|
|
1302
|
+
help: false,
|
|
1303
|
+
json: false,
|
|
1304
|
+
paid: false,
|
|
1305
|
+
query: null,
|
|
1306
|
+
limit: DEFAULT_SEARCH_LIMIT,
|
|
1307
|
+
timeoutMs: PAID_SEARCH_TIMEOUT_MS,
|
|
1308
|
+
};
|
|
1309
|
+
|
|
1310
|
+
if (args.length === 0 || ['help', '--help', '-h'].includes(args[0])) {
|
|
1311
|
+
options.help = true;
|
|
1312
|
+
return options;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
for (let i = 0; i < args.length; i++) {
|
|
1316
|
+
const arg = args[i];
|
|
1317
|
+
if (arg === '--help' || arg === '-h' || arg === 'help') {
|
|
1318
|
+
options.help = true;
|
|
1319
|
+
} else if (arg === '--json') {
|
|
1320
|
+
options.json = true;
|
|
1321
|
+
} else if (arg === '--paid') {
|
|
1322
|
+
options.paid = true;
|
|
1323
|
+
} else if (arg === '--limit') {
|
|
1324
|
+
options.limit = parsePositiveSearchInt(readValue(args, i, arg), '--limit');
|
|
1325
|
+
i++;
|
|
1326
|
+
} else if (arg.startsWith('--limit=')) {
|
|
1327
|
+
options.limit = parsePositiveSearchInt(arg.slice('--limit='.length), '--limit');
|
|
1328
|
+
} else if (arg.startsWith('-')) {
|
|
1329
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
1330
|
+
} else if (!options.query) {
|
|
1331
|
+
options.query = arg;
|
|
1332
|
+
} else {
|
|
1333
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
if (options.help) return options;
|
|
1338
|
+
if (!options.query) throw new Error('Missing query. Run "atris youtube search --help".');
|
|
1339
|
+
return options;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function parsePositiveSearchInt(raw, name) {
|
|
1343
|
+
const value = Number(raw);
|
|
1344
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
1345
|
+
throw new Error(`${name} must be a positive integer`);
|
|
1346
|
+
}
|
|
1347
|
+
return value;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function parseSearchStdout(stdout = '') {
|
|
1351
|
+
const rows = [];
|
|
1352
|
+
for (const line of String(stdout || '').split(/\r?\n/)) {
|
|
1353
|
+
const trimmed = line.trim();
|
|
1354
|
+
if (!trimmed || !trimmed.includes('|')) continue;
|
|
1355
|
+
const parts = trimmed.split(/\s*\|\s*/).map((part) => part.trim());
|
|
1356
|
+
if (parts.length < 5) continue;
|
|
1357
|
+
const url = parts[parts.length - 1];
|
|
1358
|
+
if (!/^https?:\/\/(?:www\.)?(?:youtube\.com\/|youtu\.be\/)/i.test(url)) continue;
|
|
1359
|
+
const row = {
|
|
1360
|
+
title: parts[0] || '',
|
|
1361
|
+
channel: parts[1] || '',
|
|
1362
|
+
duration: parts[2] || '',
|
|
1363
|
+
views: parts[3] || '',
|
|
1364
|
+
url,
|
|
1365
|
+
};
|
|
1366
|
+
if (parts.length >= 6) {
|
|
1367
|
+
const maybeDate = parts[parts.length - 2];
|
|
1368
|
+
if (/^\d{8}$/.test(maybeDate) || maybeDate === 'NA' || maybeDate === 'None') {
|
|
1369
|
+
row.upload_date = maybeDate === 'None' ? 'NA' : maybeDate;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
rows.push(row);
|
|
1373
|
+
}
|
|
1374
|
+
return rows;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function formatSearchResults(rows = []) {
|
|
1378
|
+
return rows.map((row) => {
|
|
1379
|
+
const parts = [row.title, row.channel, row.duration, row.views];
|
|
1380
|
+
if (row.upload_date) parts.push(row.upload_date);
|
|
1381
|
+
parts.push(row.url);
|
|
1382
|
+
return parts.join(' | ');
|
|
1383
|
+
}).join('\n');
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function normalizeSearchQuery(query) {
|
|
1387
|
+
return String(query || '').trim().toLowerCase().replace(/\s+/g, ' ');
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
function searchCacheNow(deps = {}) {
|
|
1391
|
+
if (typeof deps.now === 'function') return Number(deps.now());
|
|
1392
|
+
return Date.now();
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
function searchCacheTtlMs(deps = {}) {
|
|
1396
|
+
const ttl = Number(deps.searchCacheTtlMs);
|
|
1397
|
+
return Number.isFinite(ttl) && ttl > 0 ? ttl : LOCAL_SEARCH_CACHE_TTL_MS;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
function resolveSearchCachePath(deps = {}) {
|
|
1401
|
+
if (deps.searchCachePath) return deps.searchCachePath;
|
|
1402
|
+
const homeDir = deps.homeDir || os.homedir();
|
|
1403
|
+
return path.join(homeDir, '.atris', LOCAL_SEARCH_CACHE_FILE);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function printSearchRows(rows, options, output) {
|
|
1407
|
+
if (options.json) {
|
|
1408
|
+
output(JSON.stringify(rows, null, 2));
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
output(formatSearchResults(rows));
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function writeLocalSearchCache(query, rows, deps = {}) {
|
|
1415
|
+
if (!Array.isArray(rows) || !rows.length) return;
|
|
1416
|
+
const fsMod = deps.fs || fs;
|
|
1417
|
+
const filePath = resolveSearchCachePath(deps);
|
|
1418
|
+
try {
|
|
1419
|
+
if (typeof fsMod.mkdirSync === 'function') {
|
|
1420
|
+
fsMod.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
1421
|
+
}
|
|
1422
|
+
const payload = {
|
|
1423
|
+
query: String(query || ''),
|
|
1424
|
+
savedAt: searchCacheNow(deps),
|
|
1425
|
+
rows,
|
|
1426
|
+
};
|
|
1427
|
+
fsMod.writeFileSync(filePath, `${JSON.stringify(payload)}\n`);
|
|
1428
|
+
} catch {
|
|
1429
|
+
// cache write is best-effort; a live search already succeeded
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
function readFreshLocalSearchCache(query, deps = {}) {
|
|
1434
|
+
const fsMod = deps.fs || fs;
|
|
1435
|
+
const filePath = resolveSearchCachePath(deps);
|
|
1436
|
+
try {
|
|
1437
|
+
const parsed = JSON.parse(fsMod.readFileSync(filePath, 'utf8'));
|
|
1438
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
1439
|
+
if (normalizeSearchQuery(parsed.query) !== normalizeSearchQuery(query)) return null;
|
|
1440
|
+
const savedAt = Number(parsed.savedAt);
|
|
1441
|
+
if (!Number.isFinite(savedAt)) return null;
|
|
1442
|
+
if (searchCacheNow(deps) - savedAt >= searchCacheTtlMs(deps)) return null;
|
|
1443
|
+
if (!Array.isArray(parsed.rows) || !parsed.rows.length) return null;
|
|
1444
|
+
return parsed;
|
|
1445
|
+
} catch {
|
|
1446
|
+
return null;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function paidSearchPayload(options) {
|
|
1451
|
+
const payload = { query: options.query };
|
|
1452
|
+
if (options.limit != null) payload.limit = options.limit;
|
|
1453
|
+
return payload;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function unwrapSearchPayload(data) {
|
|
1457
|
+
if (data?.data && typeof data.data === 'object' && !Array.isArray(data.data)) {
|
|
1458
|
+
return data.data;
|
|
1459
|
+
}
|
|
1460
|
+
return data && typeof data === 'object' ? data : {};
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
function watchPermalink(item = {}) {
|
|
1464
|
+
const raw = item.url || item.permalink || item.watch_url || item.link || '';
|
|
1465
|
+
if (typeof raw === 'string' && /^https?:\/\//i.test(raw)) return raw;
|
|
1466
|
+
const id = item.video_id || item.videoId || (typeof item.id === 'string' ? item.id : '');
|
|
1467
|
+
if (id && /^[A-Za-z0-9_-]{6,}$/.test(id)) {
|
|
1468
|
+
return `https://www.youtube.com/watch?v=${id}`;
|
|
1469
|
+
}
|
|
1470
|
+
return '';
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
function paidSearchVideos(data) {
|
|
1474
|
+
const payload = unwrapSearchPayload(data);
|
|
1475
|
+
const raw = payload.results || payload.videos || payload.items
|
|
1476
|
+
|| (Array.isArray(data?.data) ? data.data : null)
|
|
1477
|
+
|| (Array.isArray(data) ? data : []);
|
|
1478
|
+
if (!Array.isArray(raw)) return [];
|
|
1479
|
+
const rows = [];
|
|
1480
|
+
for (const item of raw) {
|
|
1481
|
+
if (!item || typeof item !== 'object') continue;
|
|
1482
|
+
const url = watchPermalink(item);
|
|
1483
|
+
if (!url) continue;
|
|
1484
|
+
rows.push({
|
|
1485
|
+
title: String(item.title || item.name || '').trim(),
|
|
1486
|
+
url,
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
return rows;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
function creditsLine(data) {
|
|
1493
|
+
const payload = unwrapSearchPayload(data);
|
|
1494
|
+
const used = data?.credits_used !== undefined ? data.credits_used : payload.credits_used;
|
|
1495
|
+
const remaining = data?.credits_remaining !== undefined
|
|
1496
|
+
? data.credits_remaining
|
|
1497
|
+
: payload.credits_remaining;
|
|
1498
|
+
if (used === undefined && remaining === undefined) return '';
|
|
1499
|
+
return `Credits: ${used !== undefined ? used : '?'} used, ${remaining !== undefined ? remaining : '?'} remaining`;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
function formatPaidSearchResults(data) {
|
|
1503
|
+
const lines = paidSearchVideos(data).map((row) => (
|
|
1504
|
+
row.title ? `${row.title} | ${row.url}` : row.url
|
|
1505
|
+
));
|
|
1506
|
+
const credits = creditsLine(data);
|
|
1507
|
+
if (credits) {
|
|
1508
|
+
if (lines.length) lines.push('');
|
|
1509
|
+
lines.push(credits);
|
|
1510
|
+
}
|
|
1511
|
+
return lines.join('\n');
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
function youtubeSearchFailureError(result) {
|
|
1515
|
+
const hint = result.status === 401
|
|
1516
|
+
? ' Run "atris login --force".'
|
|
1517
|
+
: result.status === 402
|
|
1518
|
+
? ' Check Atris credits.'
|
|
1519
|
+
: result.status === 502
|
|
1520
|
+
? ' YouTube search is unavailable; retry in a few seconds.'
|
|
1521
|
+
: '';
|
|
1522
|
+
return new Error(`YouTube search failed (${result.status}): ${resultErrorText(result)}.${hint}`);
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
async function requestPaidYoutubeSearch(options, deps = {}) {
|
|
1526
|
+
const apiFn = deps.apiRequestJson || apiRequestJson;
|
|
1527
|
+
const ensureBilled = deps.ensureBilledCommandAuth || ensureBilledCommandAuth;
|
|
1528
|
+
let auth = await ensureBilled('youtube', deps);
|
|
1529
|
+
if (!auth?.ok || !auth.token) {
|
|
1530
|
+
throw new Error(auth?.error || 'not signed in. run atris login first.');
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
const body = paidSearchPayload(options);
|
|
1534
|
+
const call = (token) => apiFn('/youtube/search', {
|
|
1535
|
+
method: 'POST',
|
|
1536
|
+
token,
|
|
1537
|
+
timeoutMs: options.timeoutMs || PAID_SEARCH_TIMEOUT_MS,
|
|
1538
|
+
retries: 0,
|
|
1539
|
+
body,
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
let result = await call(auth.token);
|
|
1543
|
+
if (!result.ok && result.status === 401 && !auth.minted) {
|
|
1544
|
+
const remint = await ensureBilled('youtube', { ...deps, forceMint: true });
|
|
1545
|
+
if (remint?.ok && remint.token) {
|
|
1546
|
+
auth = remint;
|
|
1547
|
+
result = await call(auth.token);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
if (!result.ok) {
|
|
1552
|
+
throw youtubeSearchFailureError(result);
|
|
1553
|
+
}
|
|
1554
|
+
return result.data;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
async function runPaidYoutubeSearch(options, deps = {}) {
|
|
1558
|
+
const output = deps.output || ((line = '') => console.log(line));
|
|
1559
|
+
const data = await requestPaidYoutubeSearch(options, deps);
|
|
1560
|
+
if (options.json) {
|
|
1561
|
+
output(JSON.stringify(data, null, 2));
|
|
1562
|
+
return 0;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
const videos = paidSearchVideos(data);
|
|
1566
|
+
const rendered = formatPaidSearchResults(data);
|
|
1567
|
+
if (!videos.length) {
|
|
1568
|
+
output(rendered ? `no videos found\n${rendered}` : 'no videos found');
|
|
1569
|
+
return 2;
|
|
1570
|
+
}
|
|
1571
|
+
output(rendered);
|
|
1572
|
+
return 0;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
function commandOnPath(name, deps = {}) {
|
|
1576
|
+
const spawn = deps.spawnSync || spawnSync;
|
|
1577
|
+
const result = spawn('sh', ['-c', `command -v ${shellSingleQuote(name)}`], {
|
|
1578
|
+
encoding: 'utf8',
|
|
1579
|
+
timeout: 5000,
|
|
1580
|
+
});
|
|
1581
|
+
if (result.error || result.status !== 0) return null;
|
|
1582
|
+
const found = String(result.stdout || '').trim();
|
|
1583
|
+
return found || null;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
function shellSingleQuote(value) {
|
|
1587
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
function bundledYtsearchPath() {
|
|
1591
|
+
return path.join(__dirname, '..', 'scripts', 'det', 'ytsearch');
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
function defaultSearchRunner(query, limit, deps = {}) {
|
|
1595
|
+
const spawn = deps.spawnSync || spawnSync;
|
|
1596
|
+
const options = {
|
|
1597
|
+
encoding: 'utf8',
|
|
1598
|
+
timeout: 60000,
|
|
1599
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
1600
|
+
};
|
|
1601
|
+
|
|
1602
|
+
const pathBin = deps.ytsearchBin || commandOnPath('ytsearch', deps);
|
|
1603
|
+
if (pathBin) {
|
|
1604
|
+
const result = spawn(pathBin, [query, String(limit)], options);
|
|
1605
|
+
if (!(result.error && result.error.code === 'ENOENT')) return result;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
const bundled = deps.bundledYtsearch || bundledYtsearchPath();
|
|
1609
|
+
if (fs.existsSync(bundled)) {
|
|
1610
|
+
const result = spawn(bundled, [query, String(limit)], options);
|
|
1611
|
+
if (!(result.error && result.error.code === 'ENOENT')) return result;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
return spawn('yt-dlp', [
|
|
1615
|
+
'--no-update',
|
|
1616
|
+
'--flat-playlist',
|
|
1617
|
+
'--no-warnings',
|
|
1618
|
+
'--print',
|
|
1619
|
+
SEARCH_PRINT_FORMAT,
|
|
1620
|
+
`ytsearch${limit}:${query}`,
|
|
1621
|
+
], options);
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
function searchRunnerDetail(result) {
|
|
1625
|
+
return String(
|
|
1626
|
+
(result && result.stderr) || (result && result.error && result.error.message) || 'search failed',
|
|
1627
|
+
).trim();
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
function isLocalSearchRateLimited(result) {
|
|
1631
|
+
return LOCAL_SEARCH_RATE_LIMIT_RE.test(searchRunnerDetail(result));
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
function searchRunnerStatus(result) {
|
|
1635
|
+
if (result && typeof result === 'object' && 'status' in result) return result.status;
|
|
1636
|
+
return typeof result === 'number' ? result : 0;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
async function waitLocalSearchBackoff(deps = {}) {
|
|
1640
|
+
const ms = Number.isFinite(Number(deps.rateLimitBackoffMs))
|
|
1641
|
+
? Number(deps.rateLimitBackoffMs)
|
|
1642
|
+
: LOCAL_SEARCH_RATE_LIMIT_BACKOFF_MS;
|
|
1643
|
+
if (!(ms > 0)) return;
|
|
1644
|
+
if (typeof deps.sleep === 'function') return deps.sleep(ms);
|
|
1645
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
function isMissingSearchBinary(result) {
|
|
1649
|
+
return Boolean(result && typeof result === 'object' && result.error && result.error.code === 'ENOENT');
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
async function runYoutubeSearch(args = [], deps = {}) {
|
|
1653
|
+
const output = deps.output || ((line = '') => console.log(line));
|
|
1654
|
+
let options;
|
|
1655
|
+
try {
|
|
1656
|
+
options = parseSearchArgs(args);
|
|
1657
|
+
} catch (err) {
|
|
1658
|
+
output(err.message);
|
|
1659
|
+
output(YTSEARCH_USAGE);
|
|
1660
|
+
return 2;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
if (options.help) {
|
|
1664
|
+
showYoutubeSearchHelp(output, deps.commandName || 'atris youtube');
|
|
1665
|
+
return 0;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
if (options.paid) {
|
|
1669
|
+
try {
|
|
1670
|
+
return await runPaidYoutubeSearch(options, deps);
|
|
1671
|
+
} catch (err) {
|
|
1672
|
+
output(err.message);
|
|
1673
|
+
return 1;
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
const runner = deps.runner || ((query, limit) => defaultSearchRunner(query, limit, deps));
|
|
1678
|
+
let result;
|
|
1679
|
+
try {
|
|
1680
|
+
result = await Promise.resolve(runner(options.query, options.limit, deps));
|
|
1681
|
+
} catch (err) {
|
|
1682
|
+
output(String(err.message || err));
|
|
1683
|
+
return 1;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
if (isMissingSearchBinary(result)) {
|
|
1687
|
+
output('ytsearch and yt-dlp not found. Install yt-dlp or put ytsearch on PATH.');
|
|
1688
|
+
return 2;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
let status = searchRunnerStatus(result);
|
|
1692
|
+
if (status != null && status !== 0 && isLocalSearchRateLimited(result)) {
|
|
1693
|
+
await waitLocalSearchBackoff(deps);
|
|
1694
|
+
try {
|
|
1695
|
+
result = await Promise.resolve(runner(options.query, options.limit, deps));
|
|
1696
|
+
} catch (err) {
|
|
1697
|
+
output(String(err.message || err));
|
|
1698
|
+
return 1;
|
|
1699
|
+
}
|
|
1700
|
+
if (isMissingSearchBinary(result)) {
|
|
1701
|
+
output('ytsearch and yt-dlp not found. Install yt-dlp or put ytsearch on PATH.');
|
|
1702
|
+
return 2;
|
|
1703
|
+
}
|
|
1704
|
+
status = searchRunnerStatus(result);
|
|
1705
|
+
if (status != null && status !== 0 && isLocalSearchRateLimited(result)) {
|
|
1706
|
+
const cached = readFreshLocalSearchCache(options.query, deps);
|
|
1707
|
+
if (cached) {
|
|
1708
|
+
const rows = cached.rows.slice(0, options.limit);
|
|
1709
|
+
printSearchRows(rows, options, output);
|
|
1710
|
+
output(LOCAL_SEARCH_CACHE_NOTE);
|
|
1711
|
+
return 0;
|
|
1712
|
+
}
|
|
1713
|
+
output(LOCAL_SEARCH_RATE_LIMIT_MESSAGE);
|
|
1714
|
+
return status == null ? 1 : status;
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
if (status != null && status !== 0) {
|
|
1719
|
+
const detail = searchRunnerDetail(result);
|
|
1720
|
+
output(detail || 'search failed');
|
|
1721
|
+
return status == null ? 1 : status;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
const stdout = typeof result === 'string' ? result : String((result && result.stdout) || '');
|
|
1725
|
+
const rows = parseSearchStdout(stdout);
|
|
1726
|
+
if (!rows.length) {
|
|
1727
|
+
output('no videos found');
|
|
1728
|
+
return 2;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
writeLocalSearchCache(options.query, rows, deps);
|
|
1732
|
+
printSearchRows(rows, options, output);
|
|
1733
|
+
return 0;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1229
1736
|
async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
|
|
1230
1737
|
const output = deps.output || ((line = '') => console.log(line));
|
|
1738
|
+
if (argv[0] === 'search') {
|
|
1739
|
+
const code = await runYoutubeSearch(argv.slice(1), { ...deps, output });
|
|
1740
|
+
if (!deps.output && !deps.spawnSync && !deps.runner && !deps.apiRequestJson && !deps.ensureValidCredentials && !deps.ensureBilledCommandAuth) {
|
|
1741
|
+
process.exit(code);
|
|
1742
|
+
}
|
|
1743
|
+
return code;
|
|
1744
|
+
}
|
|
1231
1745
|
if (argv[0] === 'notes') {
|
|
1232
1746
|
const code = runYoutubeNotes(argv.slice(1), deps);
|
|
1233
1747
|
if (!deps.output && !deps.spawnSync && !deps.runner && !deps.expander) process.exit(code);
|
|
@@ -1248,9 +1762,18 @@ async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
|
|
|
1248
1762
|
showYoutubeHelp(output, deps.commandName || 'atris youtube');
|
|
1249
1763
|
return 0;
|
|
1250
1764
|
}
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1765
|
+
let status = 0;
|
|
1766
|
+
try {
|
|
1767
|
+
const data = await processYoutube(options, deps);
|
|
1768
|
+
output(options.json ? JSON.stringify(data, null, 2) : formatYoutubeResult(data));
|
|
1769
|
+
} catch (err) {
|
|
1770
|
+
output(err.message);
|
|
1771
|
+
status = 1;
|
|
1772
|
+
}
|
|
1773
|
+
if (!deps.output && !deps.apiRequestJson && !deps.ensureValidCredentials && !deps.ensureBilledCommandAuth && !deps.extractLocalTranscript) {
|
|
1774
|
+
process.exit(status);
|
|
1775
|
+
}
|
|
1776
|
+
return status;
|
|
1254
1777
|
}
|
|
1255
1778
|
|
|
1256
1779
|
module.exports = {
|
|
@@ -1275,5 +1798,8 @@ module.exports = {
|
|
|
1275
1798
|
parseFlatPlaylist,
|
|
1276
1799
|
loadWatchState,
|
|
1277
1800
|
watchCommand,
|
|
1801
|
+
parseSearchArgs,
|
|
1802
|
+
parseSearchStdout,
|
|
1803
|
+
formatSearchResults,
|
|
1278
1804
|
youtubeCommand,
|
|
1279
1805
|
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const ACCOUNT_GLOBAL_MESSAGE = 'account-global; pass --account to continue';
|
|
7
|
+
|
|
8
|
+
function isBoundBusinessWorkspace(cwd = process.cwd()) {
|
|
9
|
+
const root = path.resolve(cwd);
|
|
10
|
+
return fs.existsSync(path.join(root, '.atris', 'business.json'))
|
|
11
|
+
&& fs.existsSync(path.join(root, 'atris'));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isHelpArg(arg) {
|
|
15
|
+
return arg === '--help' || arg === '-h' || arg === 'help';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Account-scoped verbs dump live CRM/cloud state. Outside a bound business
|
|
20
|
+
* workspace they must opt in with --account. Help always passes.
|
|
21
|
+
* Returns { ok, args } where args has --account stripped when continuing.
|
|
22
|
+
*/
|
|
23
|
+
function requireAccountBound(args = [], options = {}) {
|
|
24
|
+
const list = Array.isArray(args) ? [...args] : [];
|
|
25
|
+
const cwd = options.cwd || process.cwd();
|
|
26
|
+
if (list.some(isHelpArg)) {
|
|
27
|
+
return { ok: true, args: list, help: true };
|
|
28
|
+
}
|
|
29
|
+
if (isBoundBusinessWorkspace(cwd)) {
|
|
30
|
+
return { ok: true, args: list.filter((arg) => arg !== '--account'), bound: true };
|
|
31
|
+
}
|
|
32
|
+
if (list.includes('--account')) {
|
|
33
|
+
return { ok: true, args: list.filter((arg) => arg !== '--account'), account: true };
|
|
34
|
+
}
|
|
35
|
+
return { ok: false, args: list, message: ACCOUNT_GLOBAL_MESSAGE };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function refuseAccountGlobal(write = console.error) {
|
|
39
|
+
write(ACCOUNT_GLOBAL_MESSAGE);
|
|
40
|
+
return 2;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = {
|
|
44
|
+
ACCOUNT_GLOBAL_MESSAGE,
|
|
45
|
+
requireAccountBound,
|
|
46
|
+
refuseAccountGlobal,
|
|
47
|
+
};
|