Ctrl K
ComunidadGuías…

bin/astrail.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node

import { createInterface } from "node:readline";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { chmodSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

import { parseArguments, validateEndpoint } from "./lib/arguments.mjs";
import { createMcpTransport } from "./lib/mcp-transport.mjs";

let parsed;
try { parsed = parseArguments(process.argv.slice(2)); } catch (error) {
  process.stderr.write(`astrail: ${error.message}\n`);
  process.exit(1);
}
const option = (name, fallback) => parsed.options[name] ?? fallback;
const [command = "help", subcommand, name] = parsed.positional;
const configPath = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "astrail", "config.json");
let diskConfig = {};
try { diskConfig = JSON.parse(readFileSync(configPath, "utf8")); } catch {}
const endpoint = option("endpoint", process.env.ASTRAIL_MCP_ENDPOINT || diskConfig.endpoint);
const apiKey = option("api-key", process.env.ASTRAIL_API_KEY || diskConfig.apiKey);
const taskToken = option("task-token", process.env.ASTRAIL_TASK_AUTHORIZATION);

async function savedConfig() {
  try { return JSON.parse(await readFile(configPath, "utf8")); } catch { return {}; }
}

async function activeConfig() { return savedConfig(); }

async function saveConfig(value) {
  const directory = join(configPath, "..");
  await mkdir(directory, { recursive: true });
  await writeFile(configPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
  chmodSync(configPath, 0o600);
}

function help() {
  process.stdout.write(`Astrail CLI

Usage:
  astrail status --endpoint URL [--api-key KEY] [--task-token TOKEN]
  astrail tools list|search|describe [NAME] --endpoint URL [--query TEXT]
  astrail connectors list|search|describe|install [ID] --endpoint URL [--query TEXT]
  astrail call TOOL --endpoint URL [--args JSON]
  astrail resume EXECUTION_ID --endpoint URL
  astrail mcp --endpoint URL
  astrail config --endpoint URL
  astrail login --endpoint URL --api-key KEY [--workspace ID]
  astrail workspace use NAME --endpoint URL --api-key KEY

Environment: ASTRAIL_MCP_ENDPOINT, ASTRAIL_API_KEY, ASTRAIL_TASK_AUTHORIZATION
`);
}

function requireEndpoint() {
  if (!endpoint) throw new Error("Set --endpoint, ASTRAIL_MCP_ENDPOINT, or run `astrail login`.");
  return validateEndpoint(endpoint);
}

let transport;
function sendMessage(message) {
  transport ??= createMcpTransport({ endpoint: requireEndpoint(), apiKey, taskToken });
  return transport(message);
}

async function rpc(method, params = {}, id = 1) {
  const payload = await sendMessage({ jsonrpc: "2.0", id, method, params });
  if (payload.error) throw new Error(payload.error.message ?? "MCP request failed.");
  return payload;
}

async function marketplace() {
  const platform = new URL(requireEndpoint());
  const response = await fetch(new URL("/api/marketplace", platform.origin), {
    headers: { accept: "application/json" },
    redirect: "manual",
    signal: AbortSignal.timeout(30_000),
  });
  const payload = await response.json().catch(() => null);
  if (!response.ok || !payload || !Array.isArray(payload.servers)) {
    throw new Error(payload?.error ?? `Marketplace returned HTTP ${response.status}.`);
  }
  return payload.servers;
}

async function installConnector(connectorId) {
  if (!apiKey) throw new Error("Connector installation requires an Astrail API key. Run `astrail login --endpoint URL --api-key KEY` first.");
  const platform = new URL(requireEndpoint());
  const response = await fetch(new URL(`/api/marketplace/${encodeURIComponent(connectorId)}/clone`, platform.origin), {
    method: "POST",
    headers: { accept: "application/json", authorization: `Bearer ${apiKey}` },
    redirect: "manual",
    signal: AbortSignal.timeout(30_000),
  });
  const payload = await response.json().catch(() => null);
  if (!response.ok || !payload?.id || !payload?.hosted_endpoint) {
    throw new Error(payload?.error ?? `Connector installation returned HTTP ${response.status}.`);
  }
  return payload;
}

function print(value) {
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}

async function main() {
  if (command === "help" || command === "--help" || command === "-h") return help();
  if (command === "login") {
    const config = await activeConfig();
    const next = { ...config, endpoint: endpoint || config.endpoint, apiKey: apiKey || config.apiKey, workspace: option("workspace", config.workspace) };
    if (!next.endpoint || !next.apiKey) throw new Error("login requires --endpoint and --api-key. Tokens are stored with mode 0600.");
    validateEndpoint(next.endpoint);
    await saveConfig(next); return print({ logged_in: true, endpoint: next.endpoint, workspace: next.workspace ?? null });
  }
  if (command === "workspace" && subcommand === "use") {
    if (!name) throw new Error("Workspace name is required.");
    const config = await activeConfig(); await saveConfig({ ...config, endpoint: endpoint || config.endpoint, apiKey: apiKey || config.apiKey, workspace: name });
    return print({ workspace: name, configured: true });
  }
  if (command === "workspace" && subcommand === "list") {
    return print((await rpc("tools/list")).result?.tools ? [{ name: name || "active", endpoint: requireEndpoint() }] : []);
  }
  if (command === "status") {
    const initialized = await rpc("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "astrail-cli", version: "0.1.0" } });
    return print({ status: "connected", endpoint: requireEndpoint(), server: initialized.result?.serverInfo, capabilities: initialized.result?.capabilities });
  }
  if (command === "tools") {
    const tools = (await rpc("tools/list")).result?.tools ?? [];
    if (subcommand === "list") return print(tools);
    if (subcommand === "search") {
      const query = (option("query", name) ?? "").toLowerCase();
      return print(tools.filter((tool) => `${tool.name} ${tool.description ?? ""}`.toLowerCase().includes(query)));
    }
    if (subcommand === "describe") {
      const tool = tools.find((item) => item.name === name);
      if (!tool) throw new Error(`Tool not found: ${name ?? ""}`);
      return print(tool);
    }
    throw new Error("Use tools list, tools search, or tools describe.");
  }
  if (command === "connectors") {
    const servers = await marketplace();
    const connectors = servers.map((server) => {
      const warnings = server.diagnostics && typeof server.diagnostics === "object" && !Array.isArray(server.diagnostics)
        ? server.diagnostics.warnings ?? []
        : Array.isArray(server.diagnostics) ? server.diagnostics : [];
      const authHelp = warnings.find((warning) => typeof warning === "string" && warning.startsWith("Authentication: "))
        ?? (Array.isArray(server.diagnostics) ? server.diagnostics.find((line) => typeof line === "string" && line.includes("Local-bridge")) : null);
      return {
        id: server.id,
        name: server.name,
        category: server.category ?? null,
        description: server.description ?? null,
        executable: Array.isArray(server.endpoint_map) && server.endpoint_map.length > 0,
        local_bridge: Array.isArray(server.diagnostics) && server.diagnostics.some((line) => String(line).includes("Local-bridge")),
        tools: Array.isArray(server.tools_json) ? server.tools_json.map((tool) => tool.name) : [],
        credential_setup: authHelp ? String(authHelp).replace(/^Authentication:\s*/, "") : null,
        docs_url: server.source_url ?? null,
      };
    });
    if (subcommand === "list") return print(connectors);
    if (subcommand === "search") {
      const query = (option("query", name) ?? "").toLowerCase();
      return print(connectors.filter((connector) => JSON.stringify(connector).toLowerCase().includes(query)));
    }
    if (subcommand === "describe") {
      const connector = connectors.find((item) => item.id === name || item.name.toLowerCase() === (name ?? "").toLowerCase());
      if (!connector) throw new Error(`Connector not found: ${name ?? ""}`);
      return print(connector);
    }
    if (subcommand === "install") {
      const connector = connectors.find((item) => item.id === name || item.name.toLowerCase() === (name ?? "").toLowerCase());
      if (!connector) throw new Error(`Connector not found: ${name ?? ""}`);
      const platform = new URL(requireEndpoint());
      const installUrl = new URL(`/marketplace/${encodeURIComponent(connector.id)}`, platform.origin).toString();
      if (!connector.executable || connector.local_bridge) {
        return print({
          connector: connector.id, name: connector.name, installed: false, executable: false, local_bridge: true,
          install_url: installUrl, credential_setup: connector.credential_setup, docs_url: connector.docs_url,
          next: [
            "This connector requires a local bridge and cannot be installed into Astrail's hosted HTTP executor.",
            `Review the local runtime instructions: ${installUrl}`,
          ],
        });
      }
      const installed = await installConnector(connector.id);
      const config = await activeConfig();
      await saveConfig({ ...config, endpoint: installed.hosted_endpoint, apiKey });
      return print({
        connector: connector.id,
        name: connector.name,
        installed: true,
        server_id: installed.id,
        executable: true,
        local_bridge: false,
        install_url: installUrl,
        credential_setup: connector.credential_setup,
        docs_url: connector.docs_url,
        hosted_endpoint: installed.hosted_endpoint,
        config_updated: true,
        next: [
          "The connector is cloned and is now the active Astrail CLI endpoint.",
          `Attach the least-privilege provider credential: ${connector.credential_setup}`,
          "Run `astrail tools list`, then call a read-only tool to verify the connection before enabling writes.",
        ],
      });
    }
    throw new Error("Use connectors list, connectors search, connectors describe, or connectors install.");
  }
  if (command === "call") {
    if (!subcommand) throw new Error("Tool name is required.");
    const rawArgs = option("args", "{}");
    let args;
    try { args = JSON.parse(rawArgs); } catch { throw new Error("--args must be valid JSON."); }
    if (!args || typeof args !== "object" || Array.isArray(args)) throw new Error("--args must be a JSON object.");
    return print((await rpc("tools/call", { name: subcommand, arguments: args })).result);
  }
  if (command === "resume") {
    if (!subcommand) throw new Error("Execution ID is required.");
    return print((await rpc("astrail/resume", { execution_id: subcommand })).result);
  }
  if (command === "config") {
    return print({ mcpServers: { astrail: { command: "astrail", args: ["mcp", "--endpoint", requireEndpoint()], env: apiKey ? { ASTRAIL_API_KEY: "<set securely>" } : {} } } });
  }
  if (command === "mcp") {
    requireEndpoint();
    const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
    for await (const line of input) {
      if (!line.trim()) continue;
      let request;
      try { request = JSON.parse(line); } catch {
        process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Invalid JSON." } })}\n`);
        continue;
      }
      if (!request || Array.isArray(request) || request.jsonrpc !== "2.0" || typeof request.method !== "string"
        || (Object.hasOwn(request, "id") && typeof request.id !== "string" && typeof request.id !== "number")) {
        process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "Invalid MCP request." } })}\n`);
        continue;
      }
      try {
        const response = await sendMessage(request);
        if (response) process.stdout.write(`${JSON.stringify(response)}\n`);
      } catch (error) {
        const message = error instanceof Error ? error.message : "CLI bridge error.";
        if (Object.hasOwn(request, "id")) {
          process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: request.id, error: { code: -32603, message } })}\n`);
        } else {
          process.stderr.write(`astrail: ${message}\n`);
        }
      }
    }
    return;
  }
  throw new Error(`Unknown command: ${command}`);
}

main().catch((error) => {
  process.stderr.write(`astrail: ${error instanceof Error ? error.message : String(error)}\n`);
  process.exitCode = 1;
});