src / toolsProvider.ts

import { tool, type ToolsProviderController } from "@lmstudio/sdk";
import { z } from "zod";
import * as fs from "fs";
import * as path from "path";

async function toolsProvider(ctl: ToolsProviderController) {
  const do_tools: any[] = [];

  // -1
  const getDateTimeTool = tool({
    name: "get_date_time",
    description: "Returns current date and time. Use only on explicit time request.",
    parameters: {},
    implementation: async () => {
      return new Date().toISOString();
    }
  });
  do_tools.push(getDateTimeTool);

  //--2
  const CWTool = tool({
    name: "count_letters",
    description: "Counts occurrences of a character in a string.",
    parameters: {
      word: z.string(),
      letr: z.string().length(1)
    },
    implementation: async ({ word, letr }) => {
      return [...word].filter(c => c === letr).length;
    }
  });
  do_tools.push(CWTool);

  //--3
  const getLengthTool = tool({
    name: "get_word_length",
    description: "Returns the length of the input string.",
    parameters: {
      word: z.string()
    },
    implementation: async ({ word }) => {
      return { length: word.length };
    }
  });
  do_tools.push(getLengthTool);

  //--4
  const getMyIPTool = tool({
    name: "get_myip",
    description: "Returns user's public IP address. Test internet connection.",
    parameters: {},
    implementation: async () => {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), 2000);
      try {
        const response = await fetch("https://ifconfig.me/ip", {
          signal: controller.signal
        });
        clearTimeout(timeoutId);
        const ip = (await response.text()).trim();
        return { ip };
      } catch (error) {
        clearTimeout(timeoutId);
        const controller2 = new AbortController();
        const timeoutId2 = setTimeout(() => controller2.abort(), 2000);
        try {
          const response = await fetch("https://api.country.is/", {
            signal: controller2.signal
          });
          clearTimeout(timeoutId2);
          const ip = (await response.text()).trim();
          return ip;
        } catch (error) {
          clearTimeout(timeoutId2);
          return { ip: null, error: "No response from 2 servers" };
        }
      }
    }
  });
  do_tools.push(getMyIPTool);

  //--5
  const getAnagramTool = tool({
    name: "get_anagram",
    description: "Returns anagram of the input string/word.",
    parameters: {
      word: z.string().describe("Input word")
    },
    implementation: async (params) => {
      const w = params.word;
      const c = [...w];
      const crypto = await import('crypto');
      //const { randomInt } = await import('crypto'); |\_ _ _ _ _just_option____
      //const j = randomInt(0, i + 1);                |/
      console.log(`Input W: "${w}", length W: ${w.length}`);
      console.log(`Input C: "${c}", length C: ${c.length}`);
      console.log(`Bytes: ${c.map(x => x.codePointAt(0))}`);
      for (let i = c.length - 1; i > 0; i--) {
        const j = crypto.randomInt(0, i +1);  //
        [c[i], c[j]] = [c[j], c[i]];          //xchg letters - (Fisher-Yates shuffle)
        console.log(`${i}:${j} C: "${c}", length: "${c.length}"`);
      }
     console.log(`result C: ${c.join("")}`);

     const result = c.join('');
     // Validity of result
     const inputSorted = [...w].sort().join('');
     const outputSorted = [...result].sort().join('');

     if (inputSorted !== outputSorted) {
       throw new Error(`Anagram validation failed: ${inputSorted} !== ${outputSorted}`);
     }
     return { anagram: result };
     }
  });
  do_tools.push(getAnagramTool);

  //--6
  // Unix timestamp -> date time
  const getTSD = tool({
    name: "ts_to_date",
    description: "Returns full date-time of input Unix ts/timestamp.",
    parameters: {
      ts: z.number().describe("Numeric value required for conversion")
    },

    implementation: async (params) => {
      // Date = Unix timestamp
      const pts = params.ts;
      const date = new Date(pts);

      const day = String(date.getDate()).padStart(2, '0');
      const month = String(date.getMonth() + 1).padStart(2, '0'); //+1: month' from zero
      const year = date.getFullYear();

      const hours = String(date.getHours()).padStart(2, '0');
      const minutes = String(date.getMinutes()).padStart(2, '0');
      const seconds = String(date.getSeconds()).padStart(2, '0');

      // day of week & month
      const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
      const months = ['January', 'February', 'March', 'April', 'May', 'June',
                      'July', 'August', 'September', 'October', 'November', 'December'];

      const dayName = days[date.getDay()];
      const monthName = months[date.getMonth()];

      // Full date: "Friday, 13 June 2025, 14:30:45"
      const fullDate = `${dayName}, ${day} ${monthName} ${year}, ${hours}:${minutes}:${seconds}`;

      return { fullDate: fullDate };
    }
  });
  do_tools.push(getTSD); //push tool 6 to provider

  //--7 : tnx Qwen code
  // scan_plugins(): Scan LM Studio plugins directory to table
  const scanPlugins = tool({
    name: "list_plugins",
    description: "Scans plugins directory and returns list of plugins with their Author, revision, installation date.",
    parameters: {},
    implementation: async () => {
      try {
        // Get the directory of the currently running plugin
        // In LM Studio plugin environment, we need to determine the plugin directory
        const currentDir = process.cwd();
        
        // Navigate up 3 levels (../../../) to reach the plugins root
        const pluginsRoot = path.resolve(currentDir, "..", "..", "..");
        
        const plugins: Array<{author: string, name: string, revision: string | null, installDate: string}> = [];

        // Recursive function to find manifest.json files
        const findManifests = (dir: string, depth: number = 0, author: string | null = null) => {
          if (!fs.existsSync(dir)) {
            return;
          }

          const entries = fs.readdirSync(dir, { withFileTypes: true });

          for (const entry of entries) {
            const fullPath = path.join(dir, entry.name);

            // Skip node_* and mcp directories
            if (entry.isDirectory() && (entry.name.startsWith("node_") || entry.name === "mcp")) {
              continue;
            }

            if (entry.isFile() && entry.name === "manifest.json") {
              // Found a manifest file
              try {
                const manifestContent = fs.readFileSync(fullPath, "utf-8");
                const manifest = JSON.parse(manifestContent);
                const stats = fs.statSync(fullPath);
                const installDate = stats.mtime.toISOString().slice(0,10); //date:yyyy-mm-dd

                // Get plugin name from parent directory name
                const pluginDir = path.basename(path.dirname(fullPath));
                
                // Author should have been passed from depth=1
                const pluginAuthor = author || "unknown";

                plugins.push({
                  author: pluginAuthor,
                  name: pluginDir,
                  revision: manifest.revision || null,
                  installDate: installDate
                });
              } catch (err) {
                console.error(`Error reading manifest: ${fullPath}`, err);
              }
            } else if (entry.isDirectory()) {
              // depth=0: pluginsRoot itself
              // depth=1: author folders (e.g., "alex")
              // depth=2: plugin folders (e.g., "myplugin")
              // At depth=1, entry.name is the author
              // At depth>1, keep the existing author
              const newAuthor = depth === 1 ? entry.name : author;
              findManifests(fullPath, depth + 1, newAuthor);
            }
          }
        };
        
        // Start scanning from plugins root
        findManifests(pluginsRoot);

        // Save list to list.json in plugin directory (one level up from current)
        const listFilePath = path.resolve(currentDir, "..", "list.json");
        fs.writeFileSync(listFilePath, JSON.stringify({ plugins, count: plugins.length }, null, 2), "utf-8");
        
        return { plugins, count: plugins.length };
      } catch (error) {
        return { 
          error: "Failed to scan plugins", 
          details: error instanceof Error ? error.message : String(error)
        };
      }
    }
  });
  do_tools.push(scanPlugins); //push tool 7 to provider

  //--8
  // updated_plugins() - Check for plugin updates
  const updatedPlugins = tool({
    name: "updated_plugins",
    description: "Checks installed plugins for updates by comparing local revision with remote manifest.json. Reports plugins with available updates.",
    parameters: {},
    implementation: async () => {
      try {
        // Read list.json from plugin directory (one level up)
        const currentDir = process.cwd();
        const listFilePath = path.resolve(currentDir, "..", "list.json");
        
        if (!fs.existsSync(listFilePath)) {
          return { error: "File list.json not found. Run list_plugins() first." };
        }

        const listData = JSON.parse(fs.readFileSync(listFilePath, "utf-8"));
        const plugins = listData.plugins || [];
        const updates: Array<{author: string, name: string, currentRevision: string, latestRevision: string}> = [];
        let checked = 0;
        let errors: Array<{author: string, name: string, error: string}> = [];

        // Check each plugin one by one with delay
        for (const plugin of plugins) {
          const manifestUrl = `https://lmstudio.ai/${plugin.author}/${plugin.name}/files/manifest.json`;
          
          try {
            const controller = new AbortController();
            const timeoutId = setTimeout(() => controller.abort(), 10000);
            
            const response = await fetch(manifestUrl, {
              signal: controller.signal,
              headers: { "User-Agent": "LMStudio-Plugin/1.0" }
            });
            clearTimeout(timeoutId);

            if (!response.ok) {
              errors.push({ author: plugin.author, name: plugin.name, error: `HTTP ${response.status}` });
              continue;
            }

            // The URL returns HTML with embedded JSON data
            // Parse revision from JSON like: "revisionNumber":8
            const html = await response.text();

            // Extract revision value from HTML
            // Pattern: "revision":<number>
            const revisionRegex = /"revision"\s*:\s*(\d+)/;
            const clrHtml = html.replace(/<[^>]*>/g, '');       //clear all tags
            const match = clrHtml.match(revisionRegex);

            if (!match) {
              errors.push({ author: plugin.author, name: plugin.name, error: "Could not parse revision from HTML" });
              continue;
            }

            const remoteRevision = match[1];    //rev.N

            if (remoteRevision && plugin.revision && parseInt(remoteRevision) > parseInt(plugin.revision)) {
              updates.push({
                author: plugin.author,
                name: plugin.name,
                currentRevision: plugin.revision,
                latestRevision: String(remoteRevision)
              });
            }

            checked++;
          } catch (err) {
            errors.push({ 
              author: plugin.author, 
              name: plugin.name, 
              error: err instanceof Error ? err.message : String(err) 
            });
          }

          // Pause between requests (1 second)
          if (checked < plugins.length) {
            await new Promise(resolve => setTimeout(resolve, 1000));
          }
        }

        // Update list.json with check results
        const updatedListData = {
          plugins: plugins.map((p: any) => {
            const update = updates.find(u => u.author === p.author && u.name === p.name);
            const error = errors.find(e => e.author === p.author && e.name === p.name);
            return {
              ...p,
              hasUpdate: !!update,
              latestRevision: update?.latestRevision || null,
              checkError: error?.error || null
            };
          }),
          count: plugins.length,
          lastChecked: new Date().toISOString(),
          updatesAvailable: updates.length
        };

        fs.writeFileSync(listFilePath, JSON.stringify(updatedListData, null, 2), "utf-8");

        // Return summary
        const summary: any = {
          checked,
          updatesAvailable: updates.length,
          errors: errors.length
        };

        if (updates.length > 0) {
          summary.updates = updates.map(u => `${u.author}/${u.name}: ${u.currentRevision} -> ${u.latestRevision}`);
        }

        if (errors.length > 0) {
          summary.errors = errors.map(e => `${e.author}/${e.name}: ${e.error}`);
        }

        if (updates.length === 0 && errors.length === 0) {
          summary.message = "All plugins are up to date";
        } else if (updates.length === 0) {
          summary.message = "No updates found, but some checks failed";
        } else {
          summary.message = `${updates.length} update(s) available`;
        }

        return summary;
      } catch (error) {
        return {
          error: "Failed to check updates",
          details: error instanceof Error ? error.message : String(error)
        };
      }
    }
  });
  do_tools.push(updatedPlugins); //push tool 8 to provider

  //--9
  const readList = tool({
    name: "read_list",
    description: "Just returns list.json with their Author, revision, installation Date, updated Flag, ...etc",
    parameters: {},
    implementation: async () => {
      try {
        // Get the directory of the currently running plugin
        const currentDir = process.cwd();
        
        // Save list to list.json in plugin directory (one level up from current)
        const FilePath = path.resolve(currentDir, "..", "list.json");
        if (!fs.existsSync(FilePath)) {
          return { error: "File list.json not found. Run list_plugins() first to create it. Only on explicit request." };
        }
        const Data = fs.readFileSync(FilePath, "utf-8");
        return Data;

        } catch (error) {
          return { error: "File list.json", details: error instanceof Error ? error.message : String(error)
          };
        }
      }
  });
  do_tools.push(readList); //push tool 9 to provider

  //--
  return do_tools;
}

export { toolsProvider };
//end.