diff --git a/ncmcp.mjs b/ncmcp.mjs index cc94b88..3faab52 100755 --- a/ncmcp.mjs +++ b/ncmcp.mjs @@ -1,12 +1,13 @@ #!/usr/bin/env node // ncmcp.mjs - CLI wrapper for nextcloud-mcp (read .env automatically) -// Usage: ncmcp [key=value ...] +// Usage: ncmcp [key=value ...] [positionalPath] [@file] [--curl] import { spawn } from "child_process"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; -import { readFileSync, existsSync } from "fs"; +import { readFileSync, existsSync, statSync } from "fs"; import { createInterface } from "readline"; +import { stdin } from "process"; const __dir = dirname(fileURLToPath(import.meta.url)); const serverPath = join(__dir, "build/index.js"); @@ -21,27 +22,169 @@ if (existsSync(envFile)) { } } +// --- Helpers --- + +function isStdinPiped() { + // stdin is a pipe (not a TTY) when used with echo ... | ncmcp ... + return !process.stdin.isTTY; +} + +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); +} + +// All 21 file tools +const FILE_TOOLS = [ + "list_files", "get_file_info", "search_files", "list_favorites", "get_quota", + "read_file", "download_file", "download_folder", + "upload_file", "create_folder", "bulk_upload", + "chunked_upload_start", "chunked_upload_chunk", "chunked_upload_finish", + "move_file", "copy_file", "delete_file", + "trash_list", "trash_restore", "trash_delete", "trash_empty", + "set_favorite", "get_file_versions", "restore_file_version", +]; + +const ALL_TOOLS = [ + "list_calendars", "get_calendar_events", "create_calendar_event", + "get_tasks", "create_task", "update_task", + "get_notes", "create_note", "get_note_content", + "get_emails", + ...FILE_TOOLS, +]; + +// --- Argument parsing --- + const [,, tool, ...rawArgs] = process.argv; -if (!tool) { - console.log("Usage: ncmcp [key=value ...]"); - console.log("Tools: list_calendars, get_calendar_events, create_calendar_event,"); - console.log(" get_tasks, create_task, update_task,"); - console.log(" get_notes, create_note, get_note_content, get_emails"); +if (!tool || tool === "--help" || tool === "-h") { + console.log(`Usage: ncmcp [key=value ...] [positionalPath] [@file] [--curl] + +Options: + --help, -h Show this help message + --curl (download_file only) Print curl command instead of calling server + +Special argument syntax: + /path/to/file Positional path (treated as path=/path/to/file) + @./local/file.txt Read local file as base64 content (for upload_file) + @./files.json Read JSON file (for bulk_upload) + Pipe content via stdin (for upload_file) + +Available tools: + Calendar: list_calendars, get_calendar_events, create_calendar_event + Tasks: get_tasks, create_task, update_task + Notes: get_notes, create_note, get_note_content + Email: get_emails + Files: list_files, get_file_info, search_files, list_favorites, get_quota + Read: read_file, download_file, download_folder + Write: upload_file, create_folder, bulk_upload + Chunked: chunked_upload_start, chunked_upload_chunk, chunked_upload_finish + Modify: move_file, copy_file, delete_file + Trash: trash_list, trash_restore, trash_delete, trash_empty + Extras: set_favorite, get_file_versions, restore_file_version`); process.exit(0); } -// Parse key=value args; values that look like JSON arrays/objects get parsed +// Detect --curl flag +const useCurl = rawArgs.includes("--curl"); +const filteredArgs = rawArgs.filter(a => a !== "--curl"); + +// Parse key=value args, positional path, and @file references const args = {}; -for (const a of rawArgs) { - const sep = a.indexOf("="); - if (sep > 0) { - const k = a.slice(0, sep); - const v = a.slice(sep + 1); - try { args[k] = JSON.parse(v); } catch { args[k] = v; } +let positionalPath = null; +let atFileContent = null; // for @file syntax on bulk_upload +let atFileEncoding = null; // encoding override when @file is used + +for (const a of filteredArgs) { + if (a.startsWith("@")) { + // @file syntax: read local file + const filePath = a.slice(1); + if (!existsSync(filePath)) { + console.error(`Error: file not found: ${filePath}`); + process.exit(1); + } + if (tool === "bulk_upload") { + // For bulk_upload: read as JSON + try { + atFileContent = JSON.parse(readFileSync(filePath, "utf8")); + } catch (e) { + console.error(`Error: invalid JSON in ${filePath}: ${e.message}`); + process.exit(1); + } + } else { + // For upload_file: read as binary → base64 + const buf = readFileSync(filePath); + atFileContent = buf.toString("base64"); + atFileEncoding = "base64"; + } + } else if (!a.startsWith("-") && a.startsWith("/")) { + // Positional path argument + positionalPath = a; + } else { + const sep = a.indexOf("="); + if (sep > 0) { + const k = a.slice(0, sep); + const v = a.slice(sep + 1); + try { args[k] = JSON.parse(v); } catch { args[k] = v; } + } } } +// Apply positional path +if (positionalPath && !args.path) { + args.path = positionalPath; +} + +// Apply @file content +if (atFileContent !== null) { + if (tool === "bulk_upload") { + // bulk_upload: pass parsed JSON as "files" arg + if (!args.files) args.files = atFileContent; + } else { + // upload_file: pass base64 content + if (!args.content) args.content = atFileContent; + if (atFileEncoding && !args.encoding) args.encoding = atFileEncoding; + } +} + +// --- download_file --curl mode --- + +if (tool === "download_file" && useCurl) { + const ncUrl = env.NEXTCLOUD_URL || ""; + const ncUser = env.NEXTCLOUD_USERNAME || ""; + const ncPass = env.NEXTCLOUD_PASSWORD || ""; + if (!ncUrl || !ncUser || !ncPass) { + console.error("Error: NEXTCLOUD_URL, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD must be set in .env"); + process.exit(1); + } + const filePath = args.path || "/"; + const baseUrl = ncUrl.replace(/\/$/, ""); + const url = `${baseUrl}/remote.php/dav/files/${ncUser}${filePath}`; + // Escape single quotes in password for shell + const escapedPass = ncPass.replace(/'/g, "'\''"); + console.log(`curl -u '${ncUser}:${escapedPass}' '${url}' -o '${filePath.split("/").pop() || "file"}'`); + process.exit(0); +} + +// --- stdin content for upload_file --- + +async function maybeReadStdin() { + // If tool accepts 'content' and stdin is piped and no content set yet + if ("content" in args || atFileContent !== null) return; + const toolsAcceptingContent = ["upload_file"]; + if (toolsAcceptingContent.includes(tool) && isStdinPiped()) { + const stdinData = await readStdin(); + if (stdinData.length > 0) { + args.content = stdinData; + } + } +} + +await maybeReadStdin(); + +// --- MCP server call --- + const child = spawn("node", [serverPath], { env, stdio: ["pipe", "pipe", "inherit"] }); const rl = createInterface({ input: child.stdout }); @@ -67,5 +210,5 @@ rl.on("line", (line) => { } catch {} }); -send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "ncmcp", version: "1.0" } } }); +send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "ncmcp", version: "2.0" } } }); setTimeout(() => { console.error("Timeout"); process.exit(1); }, 15000);