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:
@@ -1,12 +1,13 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// ncmcp.mjs - CLI wrapper for nextcloud-mcp (read .env automatically)
|
// 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 { spawn } from "child_process";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { readFileSync, existsSync } from "fs";
|
import { readFileSync, existsSync, statSync } from "fs";
|
||||||
import { createInterface } from "readline";
|
import { createInterface } from "readline";
|
||||||
|
import { stdin } from "process";
|
||||||
|
|
||||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||||
const serverPath = join(__dir, "build/index.js");
|
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;
|
const [,, tool, ...rawArgs] = process.argv;
|
||||||
|
|
||||||
if (!tool) {
|
if (!tool || tool === "--help" || tool === "-h") {
|
||||||
console.log("Usage: ncmcp <tool> [key=value ...]");
|
console.log(`Usage: ncmcp <tool> [key=value ...] [positionalPath] [@file] [--curl]
|
||||||
console.log("Tools: list_calendars, get_calendar_events, create_calendar_event,");
|
|
||||||
console.log(" get_tasks, create_task, update_task,");
|
Options:
|
||||||
console.log(" get_notes, create_note, get_note_content, get_emails");
|
--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);
|
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 = {};
|
const args = {};
|
||||||
for (const a of rawArgs) {
|
let positionalPath = null;
|
||||||
const sep = a.indexOf("=");
|
let atFileContent = null; // for @file syntax on bulk_upload
|
||||||
if (sep > 0) {
|
let atFileEncoding = null; // encoding override when @file is used
|
||||||
const k = a.slice(0, sep);
|
|
||||||
const v = a.slice(sep + 1);
|
for (const a of filteredArgs) {
|
||||||
try { args[k] = JSON.parse(v); } catch { args[k] = v; }
|
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 child = spawn("node", [serverPath], { env, stdio: ["pipe", "pipe", "inherit"] });
|
||||||
const rl = createInterface({ input: child.stdout });
|
const rl = createInterface({ input: child.stdout });
|
||||||
|
|
||||||
@@ -67,5 +210,5 @@ rl.on("line", (line) => {
|
|||||||
} catch {}
|
} 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);
|
setTimeout(() => { console.error("Timeout"); process.exit(1); }, 15000);
|
||||||
|
|||||||
Reference in New Issue
Block a user