feat(ncmcp): enhanced CLI wrapper with positional args, stdin, @file, --curl

Step 9 of file management plan:

- Positional path: ncmcp read_file /Documents/file.txt (equiv to path=/...)
- stdin content: echo 'hello' | ncmcp upload_file path=/test.txt
- @file for upload: ncmcp upload_file path=/remote.txt @./local.txt (base64)
- @file for bulk_upload: ncmcp bulk_upload @./files.json
- --curl flag: ncmcp download_file path=/file --curl prints curl cmd
- Updated help text with all 21+ file tools
- Full tool list in --help output
- Backward compatible: key=value syntax unchanged
This commit is contained in:
2026-05-11 17:28:46 +02:00
parent 40ced0f336
commit 2cb1666441
+158 -15
View File
@@ -1,12 +1,13 @@
#!/usr/bin/env node
// ncmcp.mjs - CLI wrapper for nextcloud-mcp (read .env automatically)
// Usage: ncmcp <tool> [key=value ...]
// Usage: ncmcp <tool> [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 <tool> [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 <tool> [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)
<stdin> 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);