trakt-tools 1.11.0 → 1.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,9 +36,25 @@ Or set `TRAKT_CLI_PATH` environment variable, or configure `cliPath` in plugin s
36
36
  | `trakt_history_add` | Mark movies/shows as watched |
37
37
  | `trakt_watchlist` | View watchlist |
38
38
  | `trakt_progress` | Show watch progress for TV shows |
39
- | `trakt_auth` | Set up Trakt.tv authentication |
39
+ | `trakt_auth` | Start device authorization, then poll its session after approval |
40
40
  | `trakt_sync` | Opt-in: import from streaming services (only when `syncEnabled`) |
41
41
 
42
+ ## Authentication
43
+
44
+ Use `trakt_auth` with `{ "action": "start" }` (or no parameters). Show the
45
+ returned `verification_url` and `user_code` immediately. After the user approves,
46
+ call it with `{ "action": "poll", "session": "<returned session>" }`, waiting at
47
+ least the returned `interval` between pending responses. Stop when the status is
48
+ `authenticated`, the session expires, or an error is returned. Keep the session
49
+ handle within this authorization flow.
50
+
51
+ This requires a matching `trakt-cli` with the `auth --session` option. The plugin
52
+ checks that capability before starting authentication. On POSIX systems, update
53
+ an older CLI or run `trakt-cli auth` locally for interactive setup. On Windows,
54
+ complete initial authentication with `trakt-cli auth` locally; nonblocking private
55
+ sessions are currently supported on POSIX systems. Configured client credentials
56
+ are passed in a separate child environment rather than command-line arguments.
57
+
42
58
  ## License
43
59
 
44
60
  MIT
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // src/index.ts
2
2
  import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
3
3
  import { Type } from "@sinclair/typebox";
4
+ import { Value } from "@sinclair/typebox/value";
4
5
  import { existsSync } from "fs";
5
6
  import { homedir } from "os";
6
7
  import { join } from "path";
@@ -12,6 +13,7 @@ var _runFileAsync = promisify(_runFile);
12
13
  async function runCli(cli, args, opts = {}) {
13
14
  const { stdout, stderr } = await _runFileAsync(cli, args, {
14
15
  encoding: "utf8",
16
+ env: opts.env,
15
17
  timeout: opts.timeout ?? 12e4,
16
18
  maxBuffer: opts.maxBuffer ?? 4 * 1024 * 1024
17
19
  });
@@ -185,8 +187,11 @@ var TOOLS = [
185
187
  {
186
188
  name: "trakt_auth",
187
189
  command: "auth",
188
- description: "Set up Trakt.tv authentication. Initiates OAuth device flow using configured client credentials. Only needed for initial setup.",
189
- parameters: Type.Object({})
190
+ description: "Set up Trakt.tv authentication without waiting for approval. Start returns verification_url, user_code, session, expiry and polling interval. Show the instructions immediately, then poll that session no faster than the returned interval after the user approves.",
191
+ parameters: Type.Object({
192
+ action: Type.Optional(Type.Union([Type.Literal("start"), Type.Literal("poll")])),
193
+ session: Type.Optional(Type.String({ pattern: "^[0-9a-f]{32}$" }))
194
+ })
190
195
  }
191
196
  ];
192
197
  function toolResult(text) {
@@ -214,58 +219,111 @@ function resolveCliPath(config) {
214
219
  function isAuthConfigured() {
215
220
  return existsSync(join(homedir(), ".trakt.yaml"));
216
221
  }
217
- function buildCliArgs(command, subcommand, params, config) {
222
+ function closedParameters(tool) {
223
+ return Type.Object(tool.parameters.properties, { additionalProperties: false });
224
+ }
225
+ function validParameters(schema, params) {
226
+ try {
227
+ return Value.Check(schema, params);
228
+ } catch {
229
+ return false;
230
+ }
231
+ }
232
+ function validAuthParameters(params) {
233
+ const action = params.action ?? "start";
234
+ return action === "poll" ? typeof params.session === "string" : params.session === void 0;
235
+ }
236
+ function buildCliArgs(command, subcommand, params) {
218
237
  const args = [command];
219
238
  if (command === "auth") {
220
- if (config?.clientId) args.push("--client-id", config.clientId);
221
- if (config?.clientSecret) args.push("--client-secret", config.clientSecret);
239
+ args.push("--json");
240
+ if (params.action === "poll") args.push("--session", params.session);
222
241
  return args;
223
242
  }
224
243
  if (subcommand) args.push(subcommand);
225
- if (command === "search") {
226
- if (params.query) args.push(String(params.query));
227
- }
228
- if (command === "history" && subcommand === "add") {
229
- const titles = params.titles;
230
- if (titles) {
231
- if (params.type) args.push("--type", String(params.type));
232
- if (params.watched_at) args.push("--watched-at", String(params.watched_at));
233
- for (const title of titles) {
234
- args.push(title);
235
- }
236
- args.push("--json");
237
- return args;
238
- }
239
- }
240
- const titleArrayCommands = [
241
- ["watchlist", "add"],
242
- ["watchlist", "remove"],
243
- ["history", "remove"]
244
- ];
245
- if (titleArrayCommands.some(([c, s]) => c === command && s === subcommand)) {
246
- const titles = params.titles;
247
- if (titles) {
248
- if (params.type) args.push("--type", String(params.type));
249
- for (const title of titles) {
250
- args.push(title);
251
- }
252
- args.push("--json");
253
- return args;
254
- }
255
- }
256
- const skipKeys = /* @__PURE__ */ new Set(["query", "titles"]);
244
+ const positionals = [];
245
+ if (command === "search") positionals.push(params.query);
246
+ if (Array.isArray(params.titles)) positionals.push(...params.titles);
257
247
  for (const [key, value] of Object.entries(params)) {
258
- if (skipKeys.has(key) || value === void 0 || value === null || value === false) continue;
248
+ if (key === "query" || key === "titles" || value === void 0 || value === false) continue;
259
249
  const flag = `--${key.replace(/_/g, "-")}`;
260
- if (typeof value === "boolean") {
261
- args.push(flag);
262
- } else {
263
- args.push(flag, String(value));
264
- }
250
+ if (value === true) args.push(flag);
251
+ else args.push(flag, String(value));
265
252
  }
266
253
  args.push("--json");
254
+ if (positionals.length) args.push("--", ...positionals);
267
255
  return args;
268
256
  }
257
+ function authEnvironment(config) {
258
+ return {
259
+ ...process.env,
260
+ ...config?.clientId ? { TRAKT_CLIENT_ID: config.clientId } : {},
261
+ ...config?.clientSecret ? { TRAKT_CLIENT_SECRET: config.clientSecret } : {}
262
+ };
263
+ }
264
+ async function supportsAuthSessions(cliPath) {
265
+ const env = {};
266
+ for (const key of ["PATH", "SystemRoot", "WINDIR", "ComSpec"]) {
267
+ if (process.env[key] !== void 0) env[key] = process.env[key];
268
+ }
269
+ try {
270
+ const { stdout } = await runCli(cliPath, ["auth", "--help"], {
271
+ timeout: 5e3,
272
+ maxBuffer: 256 * 1024,
273
+ env
274
+ });
275
+ return /^\s+--session(?:\s|=)/m.test(stdout);
276
+ } catch {
277
+ return false;
278
+ }
279
+ }
280
+ var AUTH_RESPONSE = Type.Object({
281
+ success: Type.Literal(true),
282
+ status: Type.Union([Type.Literal("pending"), Type.Literal("authenticated")]),
283
+ session: Type.Optional(Type.String({ pattern: "^[0-9a-f]{32}$" })),
284
+ verification_url: Type.Optional(Type.String({ minLength: 1, maxLength: 2048 })),
285
+ user_code: Type.Optional(Type.String({ pattern: "^[A-Za-z0-9-]{1,128}$" })),
286
+ expires_in: Type.Optional(Type.Integer({ minimum: 0 })),
287
+ interval: Type.Optional(Type.Integer({ minimum: 1 }))
288
+ }, { additionalProperties: false });
289
+ var AUTH_VERIFICATION_ORIGINS = /* @__PURE__ */ new Set([
290
+ "https://trakt.tv",
291
+ "https://www.trakt.tv",
292
+ "https://trakt.tv:443",
293
+ "https://www.trakt.tv:443"
294
+ ]);
295
+ function validVerificationURL(value, clientSecret) {
296
+ const pathStart = value.indexOf("/", "https://".length);
297
+ const origin = pathStart === -1 ? value : value.slice(0, pathStart);
298
+ if (!AUTH_VERIFICATION_ORIGINS.has(origin) || value.includes("?") || value.includes("#") || [...value].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) return false;
299
+ const url = new URL(value);
300
+ if (url.protocol !== "https:" || url.username || url.password || url.hostname !== "trakt.tv" && url.hostname !== "www.trakt.tv" || url.port !== "" && url.port !== "443") return false;
301
+ return !clientSecret || !decodeURIComponent(url.pathname).includes(clientSecret);
302
+ }
303
+ function parseAuthResponse(stdout, params, clientSecret) {
304
+ const result = JSON.parse(stdout);
305
+ if (!Value.Check(AUTH_RESPONSE, result)) throw new Error("Invalid auth response");
306
+ if (clientSecret && Object.values(result).some((value) => typeof value === "string" && value.includes(clientSecret))) {
307
+ throw new Error("Invalid auth response");
308
+ }
309
+ const action = params.action ?? "start";
310
+ if (result.status === "authenticated") {
311
+ if (action !== "poll" || Object.keys(result).some((key) => key !== "success" && key !== "status")) {
312
+ throw new Error("Invalid auth response");
313
+ }
314
+ return result;
315
+ }
316
+ if (!result.session || result.expires_in === void 0 || result.interval === void 0) {
317
+ throw new Error("Invalid auth response");
318
+ }
319
+ if (action === "start") {
320
+ if (!result.verification_url || !result.user_code) throw new Error("Invalid auth response");
321
+ if (!validVerificationURL(result.verification_url, clientSecret)) throw new Error("Invalid auth response");
322
+ } else if (result.session !== params.session || result.verification_url !== void 0 || result.user_code !== void 0) {
323
+ throw new Error("Invalid auth response");
324
+ }
325
+ return result;
326
+ }
269
327
  var index_default = definePluginEntry({
270
328
  id: "trakt-tools",
271
329
  name: "Trakt",
@@ -283,7 +341,7 @@ var index_default = definePluginEntry({
283
341
  name: tool.name,
284
342
  label: tool.name,
285
343
  description: tool.description,
286
- parameters: tool.parameters,
344
+ parameters: closedParameters(tool),
287
345
  async execute() {
288
346
  return toolResult(
289
347
  JSON.stringify({ success: false, error: errorMessage }, null, 2)
@@ -294,18 +352,28 @@ var index_default = definePluginEntry({
294
352
  return;
295
353
  }
296
354
  for (const tool of tools) {
355
+ const parameters = closedParameters(tool);
297
356
  api.registerTool({
298
357
  name: tool.name,
299
358
  label: tool.name,
300
359
  description: tool.description,
301
- parameters: tool.parameters,
360
+ parameters,
302
361
  async execute(_id, params) {
362
+ if (!validParameters(parameters, params) || tool.command === "auth" && !validAuthParameters(params)) {
363
+ return toolResult(JSON.stringify({ success: false, error: "Invalid parameters for this Trakt tool." }));
364
+ }
365
+ if (tool.command === "auth" && !await supportsAuthSessions(cliPath)) {
366
+ return toolResult(JSON.stringify({
367
+ success: false,
368
+ error: "This trakt-cli does not support nonblocking authentication. Update trakt-cli, or run trakt-cli auth locally."
369
+ }));
370
+ }
303
371
  if (tool.command !== "auth" && !isAuthConfigured()) {
304
372
  return toolResult(
305
373
  JSON.stringify(
306
374
  {
307
375
  success: false,
308
- error: "Trakt auth not configured. Run trakt_auth first, or manually: trakt-cli auth --client-id X --client-secret Y"
376
+ error: "Trakt auth not configured. Start trakt_auth, show its verification instructions, then poll its session. Or run trakt-cli auth locally."
309
377
  },
310
378
  null,
311
379
  2
@@ -313,12 +381,17 @@ var index_default = definePluginEntry({
313
381
  );
314
382
  }
315
383
  try {
316
- const args = buildCliArgs(tool.command, tool.subcommand, params, config);
384
+ const args = buildCliArgs(tool.command, tool.subcommand, params);
385
+ const env = tool.command === "auth" ? authEnvironment(config) : void 0;
317
386
  const { stdout } = await runCli(cliPath, args, {
318
387
  // sync walks several services plus per-show history lookups.
319
388
  timeout: tool.command === "sync" ? 6e5 : 12e4,
320
- maxBuffer: 4 * 1024 * 1024
389
+ maxBuffer: 4 * 1024 * 1024,
390
+ env
321
391
  });
392
+ if (tool.command === "auth") {
393
+ return toolResult(JSON.stringify(parseAuthResponse(stdout, params, env?.TRAKT_CLIENT_SECRET), null, 2));
394
+ }
322
395
  let result;
323
396
  try {
324
397
  result = JSON.parse(stdout);
@@ -327,6 +400,12 @@ var index_default = definePluginEntry({
327
400
  }
328
401
  return toolResult(JSON.stringify(result, null, 2));
329
402
  } catch (error) {
403
+ if (tool.command === "auth") {
404
+ return toolResult(JSON.stringify({
405
+ success: false,
406
+ error: "Trakt authentication failed. Run trakt-cli auth separately and inspect its output locally."
407
+ }));
408
+ }
330
409
  const message = error instanceof Error ? error.message : String(error);
331
410
  const stderr = error && typeof error === "object" && "stderr" in error ? String(error.stderr).trim() : "";
332
411
  const errorOutput = stderr ? `${message}
@@ -2,7 +2,7 @@
2
2
  "id": "trakt-tools",
3
3
  "name": "Trakt",
4
4
  "description": "Track movies and TV shows using trakt.tv",
5
- "version": "1.11.0",
5
+ "version": "1.11.2",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "properties": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trakt-tools",
3
- "version": "1.11.0",
3
+ "version": "1.11.2",
4
4
  "description": "OpenClaw plugin for Trakt.tv - track movies and TV shows",
5
5
  "type": "module",
6
6
  "openclaw": {
@@ -71,7 +71,8 @@
71
71
  "plugin:check": "npx --yes @openclaw/plugin-inspector inspect --no-openclaw",
72
72
  "plugin:ci": "npx --yes @openclaw/plugin-inspector ci --no-openclaw --runtime --mock-sdk --allow-execute",
73
73
  "build": "tsup src/index.ts --format esm --out-dir dist --clean --target node20",
74
- "prepack": "npm run build"
74
+ "prepack": "npm run build",
75
+ "test": "npm run build && node --experimental-test-module-mocks --test tests/*.test.mjs"
75
76
  },
76
77
  "main": "./dist/index.js"
77
78
  }
@@ -19,6 +19,17 @@ View watch history, watchlist, progress, search, and mark items as watched on Tr
19
19
 
20
20
  All commands support `--json` for machine-readable output. **Always use `--json` for data processing.**
21
21
 
22
+ ## Authentication through the tool
23
+
24
+ Call `trakt_auth` with `action: "start"` for initial setup. Present the returned
25
+ verification URL and user code immediately so the user can approve. Then call
26
+ `trakt_auth` with `action: "poll"` and the exact returned session handle. Wait at
27
+ least the returned interval before each subsequent poll. Stop on authenticated
28
+ status, expiry or an error; do not start repeated new sessions while approval is
29
+ pending. On Windows, ask the user to complete initial authentication with
30
+ `trakt-cli auth` locally. On POSIX systems, the matching CLI must support
31
+ `auth --session`; update an older CLI or use local interactive authentication.
32
+
22
33
  ## Commands
23
34
 
24
35
  ### Progress (In-Progress Shows)
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry';
9
9
  import { Type } from '@sinclair/typebox';
10
+ import { Value } from '@sinclair/typebox/value';
10
11
  import { existsSync } from 'fs';
11
12
  import { homedir } from 'os';
12
13
  import { join } from 'path';
@@ -199,8 +200,11 @@ const TOOLS: ToolDef[] = [
199
200
  name: 'trakt_auth',
200
201
  command: 'auth',
201
202
  description:
202
- 'Set up Trakt.tv authentication. Initiates OAuth device flow using configured client credentials. Only needed for initial setup.',
203
- parameters: Type.Object({}),
203
+ 'Set up Trakt.tv authentication without waiting for approval. Start returns verification_url, user_code, session, expiry and polling interval. Show the instructions immediately, then poll that session no faster than the returned interval after the user approves.',
204
+ parameters: Type.Object({
205
+ action: Type.Optional(Type.Union([Type.Literal('start'), Type.Literal('poll')])),
206
+ session: Type.Optional(Type.String({ pattern: '^[0-9a-f]{32}$' })),
207
+ }),
204
208
  },
205
209
  ];
206
210
 
@@ -243,76 +247,133 @@ function isAuthConfigured(): boolean {
243
247
  return existsSync(join(homedir(), '.trakt.yaml'));
244
248
  }
245
249
 
246
- /**
247
- * Build CLI arguments from tool parameters.
248
- * Always appends --json for machine-readable output.
249
- */
250
+ /** Keep the tool handler and advertised schema aligned on every invocation path. */
251
+ function closedParameters(tool: ToolDef): ToolDef['parameters'] {
252
+ return Type.Object(tool.parameters.properties, { additionalProperties: false });
253
+ }
254
+
255
+ function validParameters(schema: ToolDef['parameters'], params: unknown): params is Record<string, unknown> {
256
+ try {
257
+ return Value.Check(schema, params);
258
+ } catch {
259
+ return false;
260
+ }
261
+ }
262
+
263
+ function validAuthParameters(params: Record<string, unknown>): boolean {
264
+ const action = params.action ?? 'start';
265
+ return action === 'poll' ? typeof params.session === 'string' : params.session === undefined;
266
+ }
267
+
268
+ /** Options precede --, so positional values cannot select CLI flags. */
250
269
  function buildCliArgs(
251
270
  command: string,
252
271
  subcommand: string | undefined,
253
- params: Record<string, unknown>,
254
- config?: PluginConfig
272
+ params: Record<string, unknown>
255
273
  ): string[] {
256
- const args: string[] = [command];
257
-
274
+ const args = [command];
258
275
  if (command === 'auth') {
259
- if (config?.clientId) args.push('--client-id', config.clientId);
260
- if (config?.clientSecret) args.push('--client-secret', config.clientSecret);
276
+ args.push('--json');
277
+ if (params.action === 'poll') args.push('--session', params.session as string);
261
278
  return args;
262
279
  }
263
-
264
280
  if (subcommand) args.push(subcommand);
265
281
 
266
- if (command === 'search') {
267
- if (params.query) args.push(String(params.query));
268
- }
282
+ const positionals: string[] = [];
283
+ if (command === 'search') positionals.push(params.query as string);
284
+ if (Array.isArray(params.titles)) positionals.push(...(params.titles as string[]));
269
285
 
270
- if (command === 'history' && subcommand === 'add') {
271
- const titles = params.titles as string[] | undefined;
272
- if (titles) {
273
- if (params.type) args.push('--type', String(params.type));
274
- if (params.watched_at) args.push('--watched-at', String(params.watched_at));
275
- for (const title of titles) {
276
- args.push(title);
277
- }
278
- args.push('--json');
279
- return args;
280
- }
286
+ for (const [key, value] of Object.entries(params)) {
287
+ if (key === 'query' || key === 'titles' || value === undefined || value === false) continue;
288
+ const flag = `--${key.replace(/_/g, '-')}`;
289
+ if (value === true) args.push(flag);
290
+ else args.push(flag, String(value));
281
291
  }
292
+ args.push('--json');
293
+ if (positionals.length) args.push('--', ...positionals);
294
+ return args;
295
+ }
282
296
 
283
- // Title-array commands (watchlist add/remove, history remove) all share
284
- // the same shape: search by title, POST the matches. Only `history add`
285
- // needs special handling for --watched-at.
286
- const titleArrayCommands: Array<[string, string]> = [
287
- ['watchlist', 'add'],
288
- ['watchlist', 'remove'],
289
- ['history', 'remove'],
290
- ];
291
- if (titleArrayCommands.some(([c, s]) => c === command && s === subcommand)) {
292
- const titles = params.titles as string[] | undefined;
293
- if (titles) {
294
- if (params.type) args.push('--type', String(params.type));
295
- for (const title of titles) {
296
- args.push(title);
297
- }
298
- args.push('--json');
299
- return args;
300
- }
297
+ /** Create a separate environment per call; never mutate process.env. */
298
+ function authEnvironment(config?: PluginConfig): NodeJS.ProcessEnv {
299
+ return {
300
+ ...process.env,
301
+ ...(config?.clientId ? { TRAKT_CLIENT_ID: config.clientId } : {}),
302
+ ...(config?.clientSecret ? { TRAKT_CLIENT_SECRET: config.clientSecret } : {}),
303
+ };
304
+ }
305
+
306
+ /** Old CLIs ignore --json during auth and would wait before returning a code. */
307
+ async function supportsAuthSessions(cliPath: string): Promise<boolean> {
308
+ const env: NodeJS.ProcessEnv = {};
309
+ for (const key of ['PATH', 'SystemRoot', 'WINDIR', 'ComSpec']) {
310
+ if (process.env[key] !== undefined) env[key] = process.env[key];
311
+ }
312
+ try {
313
+ const { stdout } = await runCli(cliPath, ['auth', '--help'], {
314
+ timeout: 5_000,
315
+ maxBuffer: 256 * 1024,
316
+ env,
317
+ });
318
+ return /^\s+--session(?:\s|=)/m.test(stdout);
319
+ } catch {
320
+ return false;
301
321
  }
322
+ }
302
323
 
303
- const skipKeys = new Set(['query', 'titles']);
304
- for (const [key, value] of Object.entries(params)) {
305
- if (skipKeys.has(key) || value === undefined || value === null || value === false) continue;
306
- const flag = `--${key.replace(/_/g, '-')}`;
307
- if (typeof value === 'boolean') {
308
- args.push(flag);
309
- } else {
310
- args.push(flag, String(value));
324
+ const AUTH_RESPONSE = Type.Object({
325
+ success: Type.Literal(true),
326
+ status: Type.Union([Type.Literal('pending'), Type.Literal('authenticated')]),
327
+ session: Type.Optional(Type.String({ pattern: '^[0-9a-f]{32}$' })),
328
+ verification_url: Type.Optional(Type.String({ minLength: 1, maxLength: 2048 })),
329
+ user_code: Type.Optional(Type.String({ pattern: '^[A-Za-z0-9-]{1,128}$' })),
330
+ expires_in: Type.Optional(Type.Integer({ minimum: 0 })),
331
+ interval: Type.Optional(Type.Integer({ minimum: 1 })),
332
+ }, { additionalProperties: false });
333
+
334
+ const AUTH_VERIFICATION_ORIGINS = new Set([
335
+ 'https://trakt.tv', 'https://www.trakt.tv',
336
+ 'https://trakt.tv:443', 'https://www.trakt.tv:443',
337
+ ]);
338
+
339
+ /** Accept only Trakt's public verification destination and literal code data. */
340
+ function validVerificationURL(value: string, clientSecret?: string): boolean {
341
+ const pathStart = value.indexOf('/', 'https://'.length);
342
+ const origin = pathStart === -1 ? value : value.slice(0, pathStart);
343
+ if (!AUTH_VERIFICATION_ORIGINS.has(origin) || value.includes('?') || value.includes('#') ||
344
+ [...value].some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) return false;
345
+ const url = new URL(value);
346
+ if (url.protocol !== 'https:' || url.username || url.password ||
347
+ (url.hostname !== 'trakt.tv' && url.hostname !== 'www.trakt.tv') ||
348
+ (url.port !== '' && url.port !== '443')) return false;
349
+ // Match the CLI's decoded-path reflection check, not only raw JSON text.
350
+ return !clientSecret || !decodeURIComponent(url.pathname).includes(clientSecret);
351
+ }
352
+
353
+ /** Return only the public device-flow protocol, never arbitrary auth stdout. */
354
+ function parseAuthResponse(stdout: string, params: Record<string, unknown>, clientSecret?: string): unknown {
355
+ const result: unknown = JSON.parse(stdout);
356
+ if (!Value.Check(AUTH_RESPONSE, result)) throw new Error('Invalid auth response');
357
+ if (clientSecret && Object.values(result).some(value => typeof value === 'string' && value.includes(clientSecret))) {
358
+ throw new Error('Invalid auth response');
359
+ }
360
+ const action = params.action ?? 'start';
361
+ if (result.status === 'authenticated') {
362
+ if (action !== 'poll' || Object.keys(result).some(key => key !== 'success' && key !== 'status')) {
363
+ throw new Error('Invalid auth response');
311
364
  }
365
+ return result;
312
366
  }
313
-
314
- args.push('--json');
315
- return args;
367
+ if (!result.session || result.expires_in === undefined || result.interval === undefined) {
368
+ throw new Error('Invalid auth response');
369
+ }
370
+ if (action === 'start') {
371
+ if (!result.verification_url || !result.user_code) throw new Error('Invalid auth response');
372
+ if (!validVerificationURL(result.verification_url, clientSecret)) throw new Error('Invalid auth response');
373
+ } else if (result.session !== params.session || result.verification_url !== undefined || result.user_code !== undefined) {
374
+ throw new Error('Invalid auth response');
375
+ }
376
+ return result;
316
377
  }
317
378
 
318
379
  export default definePluginEntry({
@@ -336,7 +397,7 @@ export default definePluginEntry({
336
397
  name: tool.name,
337
398
  label: tool.name,
338
399
  description: tool.description,
339
- parameters: tool.parameters,
400
+ parameters: closedParameters(tool),
340
401
  async execute() {
341
402
  return toolResult(
342
403
  JSON.stringify({ success: false, error: errorMessage }, null, 2)
@@ -348,20 +409,30 @@ export default definePluginEntry({
348
409
  }
349
410
 
350
411
  for (const tool of tools) {
412
+ const parameters = closedParameters(tool);
351
413
  api.registerTool({
352
414
  name: tool.name,
353
415
  label: tool.name,
354
416
  description: tool.description,
355
- parameters: tool.parameters,
417
+ parameters,
356
418
 
357
419
  async execute(_id: string, params: Record<string, unknown>) {
420
+ if (!validParameters(parameters, params) || (tool.command === 'auth' && !validAuthParameters(params))) {
421
+ return toolResult(JSON.stringify({ success: false, error: 'Invalid parameters for this Trakt tool.' }));
422
+ }
423
+ if (tool.command === 'auth' && !await supportsAuthSessions(cliPath)) {
424
+ return toolResult(JSON.stringify({
425
+ success: false,
426
+ error: 'This trakt-cli does not support nonblocking authentication. Update trakt-cli, or run trakt-cli auth locally.',
427
+ }));
428
+ }
358
429
  if (tool.command !== 'auth' && !isAuthConfigured()) {
359
430
  return toolResult(
360
431
  JSON.stringify(
361
432
  {
362
433
  success: false,
363
434
  error:
364
- 'Trakt auth not configured. Run trakt_auth first, or manually: trakt-cli auth --client-id X --client-secret Y',
435
+ 'Trakt auth not configured. Start trakt_auth, show its verification instructions, then poll its session. Or run trakt-cli auth locally.',
365
436
  },
366
437
  null,
367
438
  2
@@ -370,7 +441,8 @@ export default definePluginEntry({
370
441
  }
371
442
 
372
443
  try {
373
- const args = buildCliArgs(tool.command, tool.subcommand, params, config);
444
+ const args = buildCliArgs(tool.command, tool.subcommand, params);
445
+ const env = tool.command === 'auth' ? authEnvironment(config) : undefined;
374
446
  // 30s was too tight for `progress`, which iterates ~50 API calls
375
447
  // (watchlist + watched + N× GetShowProgress) and empirically runs
376
448
  // 35-45s for users with substantial watch history. 120s gives
@@ -382,8 +454,13 @@ export default definePluginEntry({
382
454
  // sync walks several services plus per-show history lookups.
383
455
  timeout: tool.command === 'sync' ? 600_000 : 120_000,
384
456
  maxBuffer: 4 * 1024 * 1024,
457
+ env,
385
458
  });
386
459
 
460
+ if (tool.command === 'auth') {
461
+ return toolResult(JSON.stringify(parseAuthResponse(stdout, params, env?.TRAKT_CLIENT_SECRET), null, 2));
462
+ }
463
+
387
464
  let result: unknown;
388
465
  try {
389
466
  result = JSON.parse(stdout);
@@ -393,6 +470,14 @@ export default definePluginEntry({
393
470
 
394
471
  return toolResult(JSON.stringify(result, null, 2));
395
472
  } catch (error: unknown) {
473
+ // Authentication diagnostics can contain provider credentials.
474
+ // Never inspect or return raw authentication failures to an agent.
475
+ if (tool.command === 'auth') {
476
+ return toolResult(JSON.stringify({
477
+ success: false,
478
+ error: 'Trakt authentication failed. Run trakt-cli auth separately and inspect its output locally.',
479
+ }));
480
+ }
396
481
  const message = error instanceof Error ? error.message : String(error);
397
482
  const stderr =
398
483
  error && typeof error === 'object' && 'stderr' in error
package/src/safe-shell.ts CHANGED
@@ -21,9 +21,11 @@ const _runFileAsync = promisify(_runFile);
21
21
  export interface RunOptions {
22
22
  timeout?: number;
23
23
  maxBuffer?: number;
24
+ /** Per-child environment. Omit to inherit the parent environment. */
25
+ env?: NodeJS.ProcessEnv;
24
26
  }
25
27
 
26
- /** Run a binary with arguments and return stdout. No shell, no injection. */
28
+ /** Run a binary with an argv array and return stdout, without a shell. */
27
29
  export async function runCli(
28
30
  cli: string,
29
31
  args: string[],
@@ -31,6 +33,7 @@ export async function runCli(
31
33
  ): Promise<{ stdout: string; stderr: string }> {
32
34
  const { stdout, stderr } = await _runFileAsync(cli, args, {
33
35
  encoding: 'utf8',
36
+ env: opts.env,
34
37
  timeout: opts.timeout ?? 120_000,
35
38
  maxBuffer: opts.maxBuffer ?? 4 * 1024 * 1024,
36
39
  });