felo-ai 0.2.28 → 0.2.30

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.
@@ -0,0 +1,85 @@
1
+ {
2
+ "name": "felo-ai",
3
+ "owner": {
4
+ "name": "Felo-Inc",
5
+ "url": "https://github.com/Felo-Inc"
6
+ },
7
+ "metadata": {
8
+ "description": "Felo AI skills for Claude Code — real-time search, PPT generation, SuperAgent, LiveDoc knowledge base, web fetch, YouTube subtitles, and X (Twitter) search",
9
+ "version": "1.0.0"
10
+ },
11
+ "plugins": [
12
+ {
13
+ "name": "felo-search",
14
+ "description": "Real-time web search powered by Felo AI",
15
+ "source": "./",
16
+ "strict": false,
17
+ "skills": [
18
+ "./felo-search"
19
+ ]
20
+ },
21
+ {
22
+ "name": "felo-livedoc",
23
+ "description": "Manage knowledge bases (LiveDocs) and semantic retrieval via Felo API",
24
+ "source": "./",
25
+ "strict": false,
26
+ "skills": [
27
+ "./felo-livedoc"
28
+ ]
29
+ },
30
+ {
31
+ "name": "felo-slides",
32
+ "description": "Generate PPT presentations from a prompt using Felo AI",
33
+ "source": "./",
34
+ "strict": false,
35
+ "skills": [
36
+ "./felo-slides"
37
+ ]
38
+ },
39
+ {
40
+ "name": "felo-superAgent",
41
+ "description": "SuperAgent conversation with SSE streaming and LiveDoc integration",
42
+ "source": "./",
43
+ "strict": false,
44
+ "skills": [
45
+ "./felo-superAgent"
46
+ ]
47
+ },
48
+ {
49
+ "name": "felo-web-fetch",
50
+ "description": "Fetch and extract webpage content in markdown, text, or HTML format",
51
+ "source": "./",
52
+ "strict": false,
53
+ "skills": [
54
+ "./felo-web-fetch"
55
+ ]
56
+ },
57
+ {
58
+ "name": "felo-youtube-subtitling",
59
+ "description": "Fetch YouTube video subtitles and captions by URL or video ID",
60
+ "source": "./",
61
+ "strict": false,
62
+ "skills": [
63
+ "./felo-youtube-subtitling"
64
+ ]
65
+ },
66
+ {
67
+ "name": "felo-x-search",
68
+ "description": "Search X (Twitter) tweets, users, and replies via Felo API",
69
+ "source": "./",
70
+ "strict": false,
71
+ "skills": [
72
+ "./felo-x-search"
73
+ ]
74
+ },
75
+ {
76
+ "name": "felo-content-to-slides",
77
+ "description": "Fetch content from a webpage or YouTube video and generate a PPT",
78
+ "source": "./",
79
+ "strict": false,
80
+ "skills": [
81
+ "./felo-content-to-slides"
82
+ ]
83
+ }
84
+ ]
85
+ }
package/CHANGELOG.md CHANGED
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.29] - 2026-03-18
9
+
10
+ ### Fixed
11
+
12
+ - Fix spinner animation displaying repeated lines in non-TTY environments (e.g. Claude Code TUI, pipes, CI) by checking `process.stderr.isTTY` before writing `\r` control characters; spinner is silently skipped when stderr is not a TTY
13
+
14
+ ---
15
+
8
16
  ## [0.2.25] - 2026-03-17
9
17
 
10
18
  ### Added
@@ -56,6 +56,7 @@ felo content-to-slides -v "https://www.youtube.com/watch?v=ID" [options]
56
56
  | `-l, --language <code>` | For --video: subtitle language (e.g. en, zh-CN) |
57
57
  | `-t, --timeout <seconds>` | Fetch timeout (default 60) |
58
58
  | `--poll-timeout <seconds>` | Max seconds to wait for PPT task (default 1200) |
59
+ | `--theme <id>` | PPT theme ID (list themes with `felo ppt-themes`) |
59
60
  | `-j, --json` | Output JSON with task_id and ppt/live_doc URL |
60
61
  | `--verbose` | Show polling status |
61
62
 
@@ -67,6 +68,9 @@ Provide **either** `--url` or `--video`, not both.
67
68
  # Web page → PPT (with readability)
68
69
  node src/cli.js content-to-slides --url "https://openclaw.ai/" --readability
69
70
 
71
+ # Web page → PPT with a specific theme
72
+ node src/cli.js content-to-slides --url "https://openclaw.ai/" --readability --theme "THEME_ID"
73
+
70
74
  # YouTube → PPT, with extra instruction
71
75
  node src/cli.js content-to-slides -v "https://www.youtube.com/watch?v=xxx" --extra-prompt "max 10 slides"
72
76
 
@@ -12,6 +12,7 @@ const SPINNER_INTERVAL_MS = 80;
12
12
  const STATUS_PAD = 56;
13
13
 
14
14
  function startSpinner(message) {
15
+ if (!process.stderr.isTTY) return null;
15
16
  const start = Date.now();
16
17
  let i = 0;
17
18
  const id = setInterval(() => {
@@ -25,7 +26,7 @@ function startSpinner(message) {
25
26
 
26
27
  function stopSpinner(id) {
27
28
  if (id != null) clearInterval(id);
28
- process.stderr.write(`\r${' '.repeat(STATUS_PAD)}\r`);
29
+ if (process.stderr.isTTY) process.stderr.write(`\r${' '.repeat(STATUS_PAD)}\r`);
29
30
  }
30
31
 
31
32
  function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
@@ -75,9 +75,22 @@ node felo-slides/scripts/run_ppt_task.mjs \
75
75
  --timeout 60
76
76
  ```
77
77
 
78
+ To apply a specific theme, first list available themes with `felo ppt-themes`, then pass the theme ID:
79
+
80
+ ```bash
81
+ node felo-slides/scripts/run_ppt_task.mjs \
82
+ --query "USER_PROMPT_HERE" \
83
+ --theme "THEME_ID_HERE" \
84
+ --interval 10 \
85
+ --max-wait 1800 \
86
+ --timeout 60
87
+ ```
88
+
78
89
  Script behavior:
79
90
 
80
91
  - Creates task via `POST https://openapi.felo.ai/v2/ppts`
92
+ - Supports optional `--theme <id>` to apply a PPT theme (sends `ppt_config.ai_theme_id`)
93
+ - Supports optional `--task-id <id>` to resume polling an existing task (skips creation)
81
94
  - Polls via `GET https://openapi.felo.ai/v2/tasks/{task_id}/historical`
82
95
  - Treats `COMPLETED`/`SUCCESS` as success terminal (case-insensitive)
83
96
  - Treats `FAILED`/`ERROR` as failure terminal
@@ -152,6 +165,14 @@ Timeout handling:
152
165
 
153
166
  - If timeout reached, return last known status and instruct user to retry later
154
167
  - Include `task_id` so user can query again
168
+ - **IMPORTANT**: To resume a timed-out task, use `--task-id` instead of `--query` to avoid creating a duplicate PPT:
169
+
170
+ ```bash
171
+ node felo-slides/scripts/run_ppt_task.mjs \
172
+ --task-id "TASK_ID_HERE" \
173
+ --interval 10 \
174
+ --max-wait 1800
175
+ ```
155
176
 
156
177
  ## Important Notes
157
178
 
@@ -12,7 +12,9 @@ function usage() {
12
12
  ' node felo-slides/scripts/run_ppt_task.mjs --query "your prompt" [options]',
13
13
  '',
14
14
  'Options:',
15
- ' --query <text> PPT prompt (required)',
15
+ ' --query <text> PPT prompt (required unless --task-id is given)',
16
+ ' --task-id <id> Resume polling an existing task (skip creation)',
17
+ ' --theme <id> PPT theme ID (from ppt-themes)',
16
18
  ' --interval <seconds> Poll interval, default 10',
17
19
  ' --max-wait <seconds> Max wait time, default 1800',
18
20
  ' --timeout <seconds> Request timeout, default 60',
@@ -26,6 +28,8 @@ function usage() {
26
28
  function parseArgs(argv) {
27
29
  const out = {
28
30
  query: '',
31
+ taskId: '',
32
+ theme: '',
29
33
  intervalSec: DEFAULT_INTERVAL_SEC,
30
34
  maxWaitSec: DEFAULT_MAX_WAIT_SEC,
31
35
  timeoutSec: DEFAULT_TIMEOUT_SEC,
@@ -44,6 +48,12 @@ function parseArgs(argv) {
44
48
  } else if (a === '--query') {
45
49
  out.query = argv[i + 1] ?? '';
46
50
  i += 1;
51
+ } else if (a === '--task-id') {
52
+ out.taskId = argv[i + 1] ?? '';
53
+ i += 1;
54
+ } else if (a === '--theme') {
55
+ out.theme = argv[i + 1] ?? '';
56
+ i += 1;
47
57
  } else if (a === '--interval') {
48
58
  out.intervalSec = Number.parseInt(argv[i + 1] ?? '', 10);
49
59
  i += 1;
@@ -128,7 +138,11 @@ function extractTaskUrls(historicalData, createData) {
128
138
  };
129
139
  }
130
140
 
131
- async function createTask(apiKey, apiBase, query, timeoutMs) {
141
+ async function createTask(apiKey, apiBase, query, timeoutMs, theme) {
142
+ const reqBody = { query };
143
+ if (theme) {
144
+ reqBody.ppt_config = { ai_theme_id: theme };
145
+ }
132
146
  const payload = await fetchJson(
133
147
  `${apiBase}/v2/ppts`,
134
148
  {
@@ -138,7 +152,7 @@ async function createTask(apiKey, apiBase, query, timeoutMs) {
138
152
  Authorization: `Bearer ${apiKey}`,
139
153
  'Content-Type': 'application/json',
140
154
  },
141
- body: JSON.stringify({ query }),
155
+ body: JSON.stringify(reqBody),
142
156
  },
143
157
  timeoutMs
144
158
  );
@@ -170,7 +184,7 @@ async function main() {
170
184
  usage();
171
185
  process.exit(0);
172
186
  }
173
- if (!args.query) {
187
+ if (!args.query && !args.taskId) {
174
188
  usage();
175
189
  process.exit(1);
176
190
  }
@@ -186,10 +200,22 @@ async function main() {
186
200
  const intervalMs = args.intervalSec * 1000;
187
201
  const maxWaitMs = args.maxWaitSec * 1000;
188
202
 
189
- const createData = await createTask(apiKey, apiBase, args.query, timeoutMs);
190
- const taskId = createData.task_id;
191
- if (args.verbose) {
192
- console.error(`Task ID: ${taskId}`);
203
+ let createData = {};
204
+ let taskId;
205
+
206
+ if (args.taskId) {
207
+ // Resume polling an existing task
208
+ taskId = args.taskId;
209
+ if (args.verbose) {
210
+ console.error(`Resuming task: ${taskId}`);
211
+ }
212
+ } else {
213
+ // Create a new task
214
+ createData = await createTask(apiKey, apiBase, args.query, timeoutMs, args.theme);
215
+ taskId = createData.task_id;
216
+ if (args.verbose) {
217
+ console.error(`Task ID: ${taskId}`);
218
+ }
193
219
  }
194
220
 
195
221
  const startAt = Date.now();
@@ -7,6 +7,7 @@ const SPINNER_INTERVAL_MS = 80;
7
7
  const STATUS_PAD = 56;
8
8
 
9
9
  function startSpinner(message) {
10
+ if (!process.stderr.isTTY) return null;
10
11
  const start = Date.now();
11
12
  let i = 0;
12
13
  const id = setInterval(() => {
@@ -20,7 +21,7 @@ function startSpinner(message) {
20
21
 
21
22
  function stopSpinner(id) {
22
23
  if (id != null) clearInterval(id);
23
- process.stderr.write(`\r${' '.repeat(STATUS_PAD)}\r`);
24
+ if (process.stderr.isTTY) process.stderr.write(`\r${' '.repeat(STATUS_PAD)}\r`);
24
25
  }
25
26
 
26
27
  function usage() {
@@ -9,6 +9,7 @@ const SPINNER_INTERVAL_MS = 80;
9
9
  const STATUS_PAD = 56;
10
10
 
11
11
  function startSpinner(message) {
12
+ if (!process.stderr.isTTY) return null;
12
13
  const start = Date.now();
13
14
  let i = 0;
14
15
  const id = setInterval(() => {
@@ -22,7 +23,7 @@ function startSpinner(message) {
22
23
 
23
24
  function stopSpinner(id) {
24
25
  if (id != null) clearInterval(id);
25
- process.stderr.write(`\r${' '.repeat(STATUS_PAD)}\r`);
26
+ if (process.stderr.isTTY) process.stderr.write(`\r${' '.repeat(STATUS_PAD)}\r`);
26
27
  }
27
28
 
28
29
  function sleep(ms) {
@@ -7,6 +7,7 @@ const SPINNER_INTERVAL_MS = 80;
7
7
  const STATUS_PAD = 52;
8
8
 
9
9
  function startSpinner(message) {
10
+ if (!process.stderr.isTTY) return null;
10
11
  const start = Date.now();
11
12
  let i = 0;
12
13
  const id = setInterval(() => {
@@ -20,7 +21,7 @@ function startSpinner(message) {
20
21
 
21
22
  function stopSpinner(id) {
22
23
  if (id != null) clearInterval(id);
23
- process.stderr.write(`\r${' '.repeat(STATUS_PAD)}\r`);
24
+ if (process.stderr.isTTY) process.stderr.write(`\r${' '.repeat(STATUS_PAD)}\r`);
24
25
  }
25
26
 
26
27
  /** Extract video ID from YouTube URL or return string if plain ID. Returns null if invalid. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "felo-ai",
3
- "version": "0.2.28",
3
+ "version": "0.2.30",
4
4
  "description": "Felo AI CLI - real-time search, PPT generation, SuperAgent conversation, LiveDoc management, web fetch, YouTube subtitles, LiveDoc knowledge base, and X (Twitter) search from the terminal",
5
5
  "type": "module",
6
6
  "main": "src/cli.js",
package/src/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import { createRequire } from "module";
4
4
  import { Command } from "commander";
5
5
  import { search } from "./search.js";
6
- import { slides } from "./slides.js";
6
+ import { slides, listPptThemes } from "./slides.js";
7
7
  import { superAgent, listLiveDocs, listLiveDocResources } from "./superAgent.js";
8
8
  import { webFetch } from "./webFetch.js";
9
9
  import { youtubeSubtitling } from "./youtubeSubtitling.js";
@@ -71,7 +71,7 @@ program
71
71
  "Generate PPT/slides from a prompt (async task, outputs live doc URL when done)"
72
72
  )
73
73
  .argument(
74
- "<query>",
74
+ "[query]",
75
75
  'PPT generation prompt (e.g. "Felo, 2 pages" or "Introduction to React")'
76
76
  )
77
77
  .option("-j, --json", "output raw JSON with task_id and live_doc_url")
@@ -86,14 +86,49 @@ program
86
86
  "max seconds to wait for task completion",
87
87
  "1200"
88
88
  )
89
+ .option("--theme <id>", "PPT theme ID (from ppt-themes command)")
90
+ .option("--task-id <id>", "resume polling an existing task (skip creation)")
89
91
  .action(async (query, opts) => {
92
+ if (!query && !opts.taskId) {
93
+ console.error("Error: provide a <query> or --task-id to resume an existing task");
94
+ flushStdioThenExit(1);
95
+ return;
96
+ }
90
97
  const timeoutMs = parseInt(opts.timeout, 10) * 1000;
91
98
  const pollTimeoutMs = parseInt(opts.pollTimeout, 10) * 1000 || 1_200_000;
92
- const code = await slides(query, {
99
+ const pptConfig = opts.theme ? { ai_theme_id: opts.theme } : undefined;
100
+ const code = await slides(query || "", {
93
101
  json: opts.json,
94
102
  verbose: opts.verbose,
95
103
  timeoutMs: Number.isNaN(timeoutMs) ? 60000 : timeoutMs,
96
104
  pollTimeoutMs: Number.isNaN(pollTimeoutMs) ? 1_200_000 : pollTimeoutMs,
105
+ pptConfig,
106
+ taskId: opts.taskId,
107
+ });
108
+ process.exitCode = code;
109
+ flushStdioThenExit(code);
110
+ });
111
+
112
+ program
113
+ .command("ppt-themes")
114
+ .description("List available PPT themes with optional filtering")
115
+ .option("--lang <code>", "language code (e.g. en, zh-Hans)")
116
+ .option("--type <type>", "filter by theme type (e.g. default, custom)")
117
+ .option("-k, --keyword <keyword>", "search keyword for theme titles")
118
+ .option("-p, --page <number>", "page number (starting from 1)", "1")
119
+ .option("-s, --size <number>", "page size (max 100)", "20")
120
+ .option("-j, --json", "output raw JSON")
121
+ .option("-t, --timeout <seconds>", "request timeout in seconds", "60")
122
+ .action(async (opts) => {
123
+ const timeoutMs = parseInt(opts.timeout, 10) * 1000;
124
+ const code = await listPptThemes({
125
+ lang: opts.lang,
126
+ type: opts.type,
127
+ keyword: opts.keyword,
128
+ page: parseInt(opts.page, 10) || 1,
129
+ size: parseInt(opts.size, 10) || 20,
130
+ json: opts.json,
131
+ timeoutMs: Number.isNaN(timeoutMs) ? 60000 : timeoutMs,
97
132
  });
98
133
  process.exitCode = code;
99
134
  flushStdioThenExit(code);
@@ -341,6 +376,7 @@ program
341
376
  )
342
377
  .option("-j, --json", "Output JSON with task_id and ppt/live_doc URL")
343
378
  .option("--verbose", "Show polling status")
379
+ .option("--theme <id>", "PPT theme ID (from ppt-themes command)")
344
380
  .action(async (opts) => {
345
381
  const timeoutMs = parseInt(opts.timeout, 10) * 1000;
346
382
  const pollTimeoutMs = parseInt(opts.pollTimeout, 10) * 1000;
@@ -354,6 +390,7 @@ program
354
390
  pollTimeoutMs: Number.isNaN(pollTimeoutMs) ? 1_200_000 : pollTimeoutMs,
355
391
  json: opts.json,
356
392
  verbose: opts.verbose,
393
+ theme: opts.theme,
357
394
  });
358
395
  process.exitCode = code;
359
396
  flushStdioThenExit(code);
@@ -77,5 +77,6 @@ export async function contentToSlides(opts) {
77
77
  verbose: opts?.verbose,
78
78
  timeoutMs: fetchTimeoutMs,
79
79
  pollTimeoutMs: opts?.pollTimeoutMs,
80
+ pptConfig: opts?.theme ? { ai_theme_id: opts.theme } : undefined,
80
81
  });
81
82
  }
package/src/livedoc.js CHANGED
@@ -12,6 +12,7 @@ const STATUS_PAD = 56;
12
12
  // ── Shared helpers ──
13
13
 
14
14
  function startSpinner(message) {
15
+ if (!process.stderr.isTTY) return null;
15
16
  const start = Date.now();
16
17
  let i = 0;
17
18
  const id = setInterval(() => {
@@ -25,7 +26,7 @@ function startSpinner(message) {
25
26
 
26
27
  function stopSpinner(id) {
27
28
  if (id != null) clearInterval(id);
28
- process.stderr.write(`\r${' '.repeat(STATUS_PAD)}\r`);
29
+ if (process.stderr.isTTY) process.stderr.write(`\r${' '.repeat(STATUS_PAD)}\r`);
29
30
  }
30
31
 
31
32
  async function getApiBase() {
package/src/slides.js CHANGED
@@ -48,9 +48,18 @@ function normalizeTaskStatus(status) {
48
48
  /**
49
49
  * Create a PPT task. Returns { task_id, livedoc_short_id, ppt_business_id } or throws.
50
50
  * Uses fetchWithTimeoutAndRetry for 5xx retry (per PPT Task API error codes).
51
+ * @param {string} apiKey
52
+ * @param {string} query
53
+ * @param {number} timeoutMs
54
+ * @param {string} apiBase
55
+ * @param {{ ai_theme_id?: string }} [pptConfig]
51
56
  */
52
- async function createPptTask(apiKey, query, timeoutMs, apiBase) {
57
+ async function createPptTask(apiKey, query, timeoutMs, apiBase, pptConfig) {
53
58
  const url = `${apiBase}/v2/ppts`;
59
+ const body = { query: query.trim() };
60
+ if (pptConfig && Object.keys(pptConfig).length > 0) {
61
+ body.ppt_config = pptConfig;
62
+ }
54
63
  const res = await fetchWithTimeoutAndRetry(
55
64
  url,
56
65
  {
@@ -60,7 +69,7 @@ async function createPptTask(apiKey, query, timeoutMs, apiBase) {
60
69
  Authorization: `Bearer ${apiKey}`,
61
70
  "Content-Type": "application/json",
62
71
  },
63
- body: JSON.stringify({ query: query.trim() }),
72
+ body: JSON.stringify(body),
64
73
  },
65
74
  timeoutMs
66
75
  );
@@ -150,18 +159,30 @@ export async function slides(query, options = {}) {
150
159
  try {
151
160
  const apiBase = await getApiBase();
152
161
 
153
- process.stderr.write("Creating PPT task...\n");
162
+ let createResult = {};
163
+ let taskId;
154
164
 
155
- const createResult = await createPptTask(
156
- apiKey,
157
- query,
158
- requestTimeoutMs,
159
- apiBase
160
- );
161
- const taskId = createResult.task_id;
165
+ if (options.taskId) {
166
+ // Resume polling an existing task
167
+ taskId = options.taskId;
168
+ if (options.verbose || options.json) {
169
+ process.stderr.write(`Resuming task: ${taskId}\n`);
170
+ }
171
+ } else {
172
+ process.stderr.write("Creating PPT task...\n");
173
+
174
+ createResult = await createPptTask(
175
+ apiKey,
176
+ query,
177
+ requestTimeoutMs,
178
+ apiBase,
179
+ options.pptConfig
180
+ );
181
+ taskId = createResult.task_id;
162
182
 
163
- if (options.json && options.verbose) {
164
- process.stderr.write(`Task ID: ${taskId}\n`);
183
+ if (options.json && options.verbose) {
184
+ process.stderr.write(`Task ID: ${taskId}\n`);
185
+ }
165
186
  }
166
187
 
167
188
  // 默认显示 spinner 动画;仅在使用 -v/--json 时改为逐行状态输出
@@ -330,3 +351,79 @@ export async function slides(query, options = {}) {
330
351
  return 1;
331
352
  }
332
353
  }
354
+
355
+ /**
356
+ * List available PPT themes. Returns exit code (0 success, 1 failure).
357
+ * @param {Object} options - { lang?, type?, keyword?, page?, size?, json?, timeoutMs? }
358
+ */
359
+ export async function listPptThemes(options = {}) {
360
+ const apiKey = await getApiKey();
361
+ if (!apiKey) {
362
+ console.error(NO_KEY_MESSAGE.trim());
363
+ return 1;
364
+ }
365
+
366
+ const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
367
+
368
+ try {
369
+ const apiBase = await getApiBase();
370
+ const params = new URLSearchParams();
371
+ if (options.lang) params.set("lang", options.lang);
372
+ if (options.type) params.set("type", options.type);
373
+ if (options.keyword) params.set("keyword", options.keyword);
374
+ if (options.page) params.set("page", String(options.page));
375
+ if (options.size) params.set("size", String(options.size));
376
+
377
+ const qs = params.toString();
378
+ const url = `${apiBase}/v2/ppt-themes${qs ? `?${qs}` : ""}`;
379
+
380
+ const res = await fetchWithTimeoutAndRetry(
381
+ url,
382
+ {
383
+ method: "GET",
384
+ headers: {
385
+ Accept: "application/json",
386
+ Authorization: `Bearer ${apiKey}`,
387
+ },
388
+ },
389
+ timeoutMs
390
+ );
391
+
392
+ const data = await res.json().catch(() => ({}));
393
+
394
+ if (data.status === "error") {
395
+ const msg = data.message || data.code || "Unknown error";
396
+ throw new Error(msg);
397
+ }
398
+
399
+ if (!res.ok) {
400
+ const msg =
401
+ data.message || data.error || res.statusText || `HTTP ${res.status}`;
402
+ throw new Error(msg);
403
+ }
404
+
405
+ const themes = data.data ?? [];
406
+
407
+ if (options.json) {
408
+ console.log(JSON.stringify(data, null, 2));
409
+ return 0;
410
+ }
411
+
412
+ if (!Array.isArray(themes) || themes.length === 0) {
413
+ console.log("No themes found.");
414
+ return 0;
415
+ }
416
+
417
+ for (const t of themes) {
418
+ console.log(`${t.id} ${t.title || "(untitled)"}`);
419
+ if (t.subtitle) console.log(` subtitle: ${t.subtitle}`);
420
+ if (t.description) console.log(` ${t.description}`);
421
+ console.log();
422
+ }
423
+
424
+ return 0;
425
+ } catch (err) {
426
+ console.error("Error:", err.message || err);
427
+ return 1;
428
+ }
429
+ }
package/src/webFetch.js CHANGED
@@ -8,6 +8,7 @@ const SPINNER_INTERVAL_MS = 80;
8
8
  const STATUS_PAD = 56;
9
9
 
10
10
  function startSpinner(message) {
11
+ if (!process.stderr.isTTY) return null;
11
12
  const start = Date.now();
12
13
  let i = 0;
13
14
  const id = setInterval(() => {
@@ -21,7 +22,7 @@ function startSpinner(message) {
21
22
 
22
23
  function stopSpinner(id) {
23
24
  if (id != null) clearInterval(id);
24
- process.stderr.write(`\r${' '.repeat(STATUS_PAD)}\r`);
25
+ if (process.stderr.isTTY) process.stderr.write(`\r${' '.repeat(STATUS_PAD)}\r`);
25
26
  }
26
27
 
27
28
  async function getApiBase() {
package/src/xSearch.js CHANGED
@@ -8,6 +8,7 @@ const SPINNER_INTERVAL_MS = 80;
8
8
  const STATUS_PAD = 56;
9
9
 
10
10
  function startSpinner(message) {
11
+ if (!process.stderr.isTTY) return null;
11
12
  const start = Date.now();
12
13
  let i = 0;
13
14
  const id = setInterval(() => {
@@ -21,7 +22,7 @@ function startSpinner(message) {
21
22
 
22
23
  function stopSpinner(id) {
23
24
  if (id != null) clearInterval(id);
24
- process.stderr.write(`\r${' '.repeat(STATUS_PAD)}\r`);
25
+ if (process.stderr.isTTY) process.stderr.write(`\r${' '.repeat(STATUS_PAD)}\r`);
25
26
  }
26
27
 
27
28
  async function getApiBase() {
@@ -8,6 +8,7 @@ const SPINNER_INTERVAL_MS = 80;
8
8
  const STATUS_PAD = 52;
9
9
 
10
10
  function startSpinner(message) {
11
+ if (!process.stderr.isTTY) return null;
11
12
  const start = Date.now();
12
13
  let i = 0;
13
14
  const id = setInterval(() => {
@@ -21,7 +22,7 @@ function startSpinner(message) {
21
22
 
22
23
  function stopSpinner(id) {
23
24
  if (id != null) clearInterval(id);
24
- process.stderr.write(`\r${' '.repeat(STATUS_PAD)}\r`);
25
+ if (process.stderr.isTTY) process.stderr.write(`\r${' '.repeat(STATUS_PAD)}\r`);
25
26
  }
26
27
 
27
28
  /** Extract video ID from a YouTube URL or return the string if it looks like a plain ID. Returns null if invalid. */