atris 3.52.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.
Files changed (131) hide show
  1. package/FOR_AGENTS.md +7 -0
  2. package/README.md +1 -1
  3. package/atris/policies/ANTISLOP.md +2 -1
  4. package/atris/skills/copy-editor/SKILL.md +2 -1
  5. package/atris/skills/design/SKILL.md +1 -1
  6. package/atris/skills/engines/SKILL.md +8 -7
  7. package/atris/skills/x-search/SKILL.md +24 -22
  8. package/atris/skills/youtube/SKILL.md +89 -40
  9. package/atris/team/_template/SOUL.md +40 -0
  10. package/atris/team/validator/MEMBER.md +1 -2
  11. package/ax +592 -91
  12. package/bin/atris +21 -0
  13. package/bin/atris.js +580 -378
  14. package/commands/api-key.js +170 -0
  15. package/commands/auth.js +453 -43
  16. package/commands/autoland.js +3 -1
  17. package/commands/autopilot-front.js +58 -11
  18. package/commands/avail.js +5 -4
  19. package/commands/balance.js +55 -0
  20. package/commands/bench.js +29 -1
  21. package/commands/brain.js +63 -12
  22. package/commands/brainstorm.js +18 -0
  23. package/commands/business.js +149 -1
  24. package/commands/chat-scan.js +17 -4
  25. package/commands/close.js +5 -1
  26. package/commands/computer.js +2 -2
  27. package/commands/doctor.js +174 -0
  28. package/commands/dream.js +7 -0
  29. package/commands/drill.js +34 -2
  30. package/commands/engine.js +47 -35
  31. package/commands/errors.js +58 -8
  32. package/commands/experiments.js +14 -0
  33. package/commands/feedback.js +61 -2
  34. package/commands/fleet-report.js +27 -7
  35. package/commands/fleet.js +98 -11
  36. package/commands/founder.js +60 -21
  37. package/commands/human-missions.js +79 -4
  38. package/commands/improve.js +54 -4
  39. package/commands/init.js +68 -92
  40. package/commands/install.js +40 -0
  41. package/commands/integrations.js +399 -30
  42. package/commands/interview.js +8 -0
  43. package/commands/land.js +48 -17
  44. package/commands/learn.js +29 -4
  45. package/commands/log.js +51 -0
  46. package/commands/member.js +68 -10
  47. package/commands/mission.js +339 -74
  48. package/commands/now.js +25 -6
  49. package/commands/orb.js +97 -2
  50. package/commands/pack.js +83 -9
  51. package/commands/playbook.js +381 -0
  52. package/commands/plugin.js +16 -0
  53. package/commands/radar.js +96 -40
  54. package/commands/recap.js +128 -21
  55. package/commands/release.js +102 -2
  56. package/commands/review.js +5 -10
  57. package/commands/revisions.js +55 -0
  58. package/commands/run-front.js +12 -4
  59. package/commands/scout.js +12 -1
  60. package/commands/search.js +123 -28
  61. package/commands/sign.js +2 -2
  62. package/commands/signup.js +12 -3
  63. package/commands/site-deploy.js +17 -4
  64. package/commands/site.js +2 -2
  65. package/commands/skill-eval.js +289 -0
  66. package/commands/skill.js +56 -6
  67. package/commands/social.js +603 -0
  68. package/commands/spaceship.js +69 -6
  69. package/commands/stream.js +8 -1
  70. package/commands/study.js +32 -19
  71. package/commands/sync.js +28 -22
  72. package/commands/task.js +418 -60
  73. package/commands/teach.js +72 -63
  74. package/commands/terminal.js +25 -9
  75. package/commands/topup.js +88 -0
  76. package/commands/truth.js +52 -6
  77. package/commands/usage.js +91 -0
  78. package/commands/verify.js +3 -2
  79. package/commands/version.js +3 -1
  80. package/commands/voice.js +5 -1
  81. package/commands/who.js +69 -11
  82. package/commands/wiki.js +7 -4
  83. package/commands/wish.js +76 -5
  84. package/commands/workflow.js +327 -145
  85. package/commands/worktree.js +1 -0
  86. package/commands/write.js +5 -0
  87. package/commands/x-search.js +340 -0
  88. package/commands/xp.js +5 -1
  89. package/commands/youtube.js +551 -33
  90. package/lib/account-bound.js +47 -0
  91. package/lib/auto-accept-certified.js +10 -2
  92. package/lib/auto-lessons.js +186 -0
  93. package/lib/autoland.js +4 -2
  94. package/lib/chat-log-scan.js +7 -4
  95. package/lib/cli-json.js +77 -0
  96. package/lib/cli-scope.js +26 -0
  97. package/lib/codex-flight.js +1 -0
  98. package/lib/conductor-artifacts.js +1 -1
  99. package/lib/context-gatherer.js +11 -0
  100. package/lib/developer-api.js +116 -0
  101. package/lib/engine-ask.js +11 -3
  102. package/lib/engine-registry.js +32 -7
  103. package/lib/feedback-triage.js +274 -0
  104. package/lib/first-minute.js +398 -0
  105. package/lib/fleet.js +115 -34
  106. package/lib/functional-owner.js +22 -0
  107. package/lib/known-commands.js +31 -5
  108. package/lib/member-scaffold.js +197 -0
  109. package/lib/mission-ledger-compact.js +127 -0
  110. package/lib/mission-root.js +4 -2
  111. package/lib/mission-runtime-loop.js +30 -2
  112. package/lib/next-moves.js +34 -34
  113. package/lib/noninteractive.js +88 -0
  114. package/lib/permission-grants.js +10 -1
  115. package/lib/policy-lessons.js +4 -1
  116. package/lib/revision-metric.js +279 -0
  117. package/lib/runner-command.js +5 -4
  118. package/lib/scratch-root.js +48 -0
  119. package/lib/skill-eval-gate.js +249 -0
  120. package/lib/task-db.js +22 -2
  121. package/lib/task-proof.js +75 -9
  122. package/lib/task-receipt.js +5 -1
  123. package/lib/verifier-quality.js +69 -0
  124. package/lib/wish-audit.js +2 -2
  125. package/lib/wish-delegate.js +10 -1
  126. package/lib/workspace-scaffold.js +270 -0
  127. package/package.json +4 -2
  128. package/scripts/det/checklist-score.js +191 -0
  129. package/scripts/det/ytsearch +31 -0
  130. package/scripts/outbound-artifact-gate.js +227 -0
  131. package/utils/update-check.js +15 -0
@@ -1,7 +1,8 @@
1
1
  const { apiRequestJson } = require('../utils/api');
2
- const { ensureValidCredentials } = require('../utils/auth');
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} notes <youtube-url> [youtube-url-or-playlist...] [engine]`);
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 ensureFn = deps.ensureValidCredentials || ensureValidCredentials;
388
- const ensured = await ensureFn(apiFn);
389
- const creds = ensured && ensured.credentials;
390
- if (!creds?.token) {
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 apiFn('/agent/process_youtube', {
405
- method: 'POST',
406
- token: creds.token,
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 apiFn('/agent/process_youtube', {
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
- const data = await processYoutube(options, deps);
1252
- output(options.json ? JSON.stringify(data, null, 2) : formatYoutubeResult(data));
1253
- return 0;
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 = {
@@ -1263,25 +1786,20 @@ module.exports = {
1263
1786
  shouldRetryWithLocalTranscript,
1264
1787
  formatYoutubeResult,
1265
1788
  fileBriefFromNotes,
1266
- looksLikeYoutubeUrl,
1267
1789
  isPlaylistUrl,
1268
1790
  parseNotesArgs,
1269
1791
  expandNotesTargets,
1270
1792
  runYoutubeNotesBatch,
1271
- runYoutubeNotes,
1272
1793
  parseDigestArgs,
1273
1794
  collectVideoBriefs,
1274
1795
  buildDigestPrompt,
1275
- runYoutubeDigest,
1276
1796
  normalizeWatchChannel,
1277
1797
  channelVideosUrl,
1278
1798
  parseFlatPlaylist,
1279
1799
  loadWatchState,
1280
- saveWatchState,
1281
- addWatchChannel,
1282
- listWatchChannels,
1283
- removeWatchChannel,
1284
- tickWatch,
1285
1800
  watchCommand,
1801
+ parseSearchArgs,
1802
+ parseSearchStdout,
1803
+ formatSearchResults,
1286
1804
  youtubeCommand,
1287
1805
  };