yungle-cli 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,6 +27,8 @@ starting over.
27
27
  | `yungle push <paths…> --collection <id>` | Upload into a collection |
28
28
  | `yungle ls transfers\|collections\|contacts` | List things |
29
29
  | `yungle rm transfer <id>` | Revoke a transfer |
30
+ | `yungle mcp install` | Let an AI assistant read your Yungle |
31
+ | `yungle mcp status` | Show where that is installed |
30
32
 
31
33
  `--json` on any command prints a machine-readable object on stdout; progress
32
34
  goes to stderr, so `yungle send … --json \| jq` works.
@@ -34,6 +36,36 @@ goes to stderr, so `yungle send … --json \| jq` works.
34
36
  Folders keep their structure. Hidden files are skipped when walking a directory
35
37
  and kept when named directly.
36
38
 
39
+ ## Letting an assistant read your Yungle
40
+
41
+ ```bash
42
+ yungle mcp install
43
+ ```
44
+
45
+ Writes the [Yungle MCP server](https://www.npmjs.com/package/yungle-mcp) into
46
+ whichever clients you have — Claude Desktop, Claude Code, Cursor, Windsurf —
47
+ so you can ask *"what did I send last week?"* or *"did the client download it
48
+ yet?"* instead of opening the dashboard. Restart the client afterwards; they
49
+ read their MCP config at startup.
50
+
51
+ `--dry-run` shows what would change and writes nothing. `--client <id>` targets
52
+ one. Re-running is safe: it reports `already current` when nothing needs doing,
53
+ and updates the entry if your key has changed.
54
+
55
+ **It refuses a key that can write.** The MCP server has no tool that sends,
56
+ invites, revokes or deletes, so a write scope grants an assistant nothing it can
57
+ use — while putting a credential that *could* change your account into a file an
58
+ assistant reads. Create a read-only key at
59
+ [yungle.co/dashboard/settings/api](https://yungle.co/dashboard/settings/api);
60
+ reading the API is free on any plan.
61
+
62
+ Two things worth knowing. Your key ends up in that client's config file in plain
63
+ text — that is how stdio MCP servers receive credentials, and it is why the
64
+ read-only rule above is enforced rather than suggested. And a config file that
65
+ does not parse is **refused, never overwritten**: it holds every other MCP server
66
+ you have set up, and a trailing comma is not a reason to replace it. The previous
67
+ contents are copied to `<config>.yungle-bak` before any change.
68
+
37
69
  ## Resuming
38
70
 
39
71
  State lives in `~/.cache/yungle/`. A session is keyed by the *local files* —
package/dist/yungle.mjs CHANGED
@@ -204,7 +204,13 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
204
204
  "folder",
205
205
  "out",
206
206
  "url",
207
- "key"
207
+ "key",
208
+ // `yungle mcp install --client cursor` silently installed into EVERY client
209
+ // before this was added: the flag parsed as a boolean, `cursor` became a stray
210
+ // positional, and the filter was never applied. The help text promised
211
+ // `--client <id>`, so the command line was correct and the parser was not.
212
+ // `helpFlagsAreParsed` in args.test.ts now derives this from HELP.
213
+ "client"
208
214
  ]);
209
215
  function parseArgs(argv) {
210
216
  const [command = "help", ...rest] = argv;
@@ -417,6 +423,82 @@ function formatBytes(bytes) {
417
423
  return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
418
424
  }
419
425
 
426
+ // src/mcp-install.ts
427
+ import { homedir as homedir2, platform } from "node:os";
428
+ import { join as join3 } from "node:path";
429
+ function mcpClients(os = platform(), home = homedir2(), env = process.env) {
430
+ const appData = env.APPDATA ?? join3(home, "AppData", "Roaming");
431
+ return [
432
+ {
433
+ id: "claude-desktop",
434
+ label: "Claude Desktop",
435
+ serversKey: "mcpServers",
436
+ configPath: os === "darwin" ? join3(home, "Library", "Application Support", "Claude", "claude_desktop_config.json") : os === "win32" ? join3(appData, "Claude", "claude_desktop_config.json") : (
437
+ // No Linux build of Claude Desktop exists, so there is nothing to write.
438
+ null
439
+ )
440
+ },
441
+ {
442
+ id: "claude-code",
443
+ label: "Claude Code",
444
+ serversKey: "mcpServers",
445
+ configPath: join3(home, ".claude.json"),
446
+ viaCli: {
447
+ bin: "claude",
448
+ // `-s user` so it applies everywhere, not just the directory the install
449
+ // happened to be run from — the default scope is `local`, which would
450
+ // silently give a per-project install nobody asked for.
451
+ args: (name, json) => ["mcp", "add-json", "-s", "user", name, json]
452
+ }
453
+ },
454
+ {
455
+ id: "cursor",
456
+ label: "Cursor",
457
+ serversKey: "mcpServers",
458
+ configPath: join3(home, ".cursor", "mcp.json")
459
+ },
460
+ {
461
+ id: "windsurf",
462
+ label: "Windsurf",
463
+ serversKey: "mcpServers",
464
+ configPath: join3(home, ".codeium", "windsurf", "mcp_config.json")
465
+ }
466
+ ];
467
+ }
468
+ function serverEntry(opts) {
469
+ const env = { YUNGLE_API_KEY: opts.apiKey };
470
+ if (opts.baseUrl) env.YUNGLE_API_URL = opts.baseUrl;
471
+ return { command: "npx", args: ["-y", "yungle-mcp"], env };
472
+ }
473
+ function mergeServerEntry(existing, serversKey, name, entry) {
474
+ const root = existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : {};
475
+ const servers = root[serversKey];
476
+ const nextServers = servers && typeof servers === "object" && !Array.isArray(servers) ? { ...servers } : {};
477
+ const before = nextServers[name];
478
+ if (before !== void 0 && deepEqual(before, entry)) return { kind: "unchanged" };
479
+ nextServers[name] = entry;
480
+ root[serversKey] = nextServers;
481
+ return before === void 0 ? { kind: "added", config: root } : { kind: "updated", config: root, previous: before };
482
+ }
483
+ function deepEqual(a, b) {
484
+ if (a === b) return true;
485
+ if (typeof a !== typeof b || a === null || b === null) return false;
486
+ if (Array.isArray(a) || Array.isArray(b)) {
487
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
488
+ return a.every((v, i) => deepEqual(v, b[i]));
489
+ }
490
+ if (typeof a !== "object") return false;
491
+ const ka = Object.keys(a);
492
+ const kb = Object.keys(b);
493
+ if (ka.length !== kb.length) return false;
494
+ return ka.every(
495
+ (k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k])
496
+ );
497
+ }
498
+ function writeScopes(scopes) {
499
+ return scopes.filter((s) => s.endsWith(":write"));
500
+ }
501
+
420
502
  // src/upload.ts
421
503
  import { createReadStream } from "node:fs";
422
504
  import { stat as stat2 } from "node:fs/promises";
@@ -517,6 +599,10 @@ var HELP = `yungle \u2014 send and sync files with Yungle
517
599
  --folder <id> target folder
518
600
  yungle ls transfers|collections|contacts
519
601
  yungle rm transfer <id> revoke a transfer
602
+ yungle mcp install let an AI assistant read your Yungle
603
+ --client <id> claude-desktop | claude-code | cursor | windsurf
604
+ --dry-run show what would change, write nothing
605
+ yungle mcp status show where it is installed
520
606
 
521
607
  Global flags
522
608
  --json machine-readable output
@@ -553,6 +639,8 @@ async function main(argv) {
553
639
  return await list(positionals, flags, json);
554
640
  case "rm":
555
641
  return await remove(positionals, flags, json);
642
+ case "mcp":
643
+ return await mcp(positionals, flags, json);
556
644
  default:
557
645
  process.stderr.write(`Unknown command: ${command}
558
646
 
@@ -857,7 +945,186 @@ function fail(err, json) {
857
945
  `);
858
946
  return 1;
859
947
  }
948
+ async function mcp(positionals, flags, json) {
949
+ const sub = positionals[0] ?? "install";
950
+ if (sub !== "install" && sub !== "status") {
951
+ process.stderr.write("Usage: yungle mcp install | yungle mcp status\n");
952
+ return 2;
953
+ }
954
+ const { readFile: readFile2, writeFile: writeFile2, mkdir: mkdir2, copyFile } = await import("node:fs/promises");
955
+ const { dirname: dirname2 } = await import("node:path");
956
+ const all = mcpClients();
957
+ const only = stringFlag(flags.client);
958
+ if (only && !all.some((c) => c.id === only)) {
959
+ process.stderr.write(
960
+ `Unknown client: ${only}. Known: ${all.map((c) => c.id).join(", ")}
961
+ `
962
+ );
963
+ return 2;
964
+ }
965
+ const targets = only ? all.filter((c) => c.id === only) : all;
966
+ if (sub === "status") {
967
+ const rows = [];
968
+ for (const c of targets) {
969
+ let installed = false;
970
+ if (c.configPath) {
971
+ try {
972
+ const doc = JSON.parse(await readFile2(c.configPath, "utf8"));
973
+ const servers = doc[c.serversKey];
974
+ installed = !!servers && typeof servers === "object" && "yungle" in servers;
975
+ } catch {
976
+ installed = false;
977
+ }
978
+ }
979
+ rows.push({ client: c.id, path: c.configPath, installed });
980
+ }
981
+ return out(
982
+ json,
983
+ { clients: rows },
984
+ rows.map(
985
+ (r) => `${pad(r.client, 16)} ${r.installed ? "installed" : r.path ? "not installed" : "n/a on this platform"}`
986
+ ).join("\n")
987
+ );
988
+ }
989
+ const resolved = await resolveKey();
990
+ if (!resolved) {
991
+ process.stderr.write(
992
+ `No API key. Run \`yungle auth login\` first, or set YUNGLE_API_KEY.
993
+ Get a key at https://yungle.co/dashboard/settings/api \u2014 reading is free on any plan.
994
+ `
995
+ );
996
+ return 1;
997
+ }
998
+ let scopes;
999
+ try {
1000
+ const me = await client(flags).me();
1001
+ scopes = me.key.scopes ?? [];
1002
+ } catch (err) {
1003
+ process.stderr.write(
1004
+ `Could not check what this key is allowed to do: ${err.message}
1005
+ Not installing \u2014 an agent should never get a key of unknown scope.
1006
+ `
1007
+ );
1008
+ return 1;
1009
+ }
1010
+ const writes = writeScopes(scopes);
1011
+ if (writes.length > 0) {
1012
+ process.stderr.write(
1013
+ `This key can write (${writes.join(", ")}).
1014
+
1015
+ The MCP server has no tool that sends, invites, revokes or deletes, so a
1016
+ write scope grants an assistant nothing it can use \u2014 but it does put a
1017
+ credential that can change your account into a config file an assistant
1018
+ reads. Create a read-only key instead:
1019
+
1020
+ https://yungle.co/dashboard/settings/api
1021
+
1022
+ then \`yungle auth login\` with it and re-run this.
1023
+ `
1024
+ );
1025
+ return 1;
1026
+ }
1027
+ const entry = serverEntry({ apiKey: resolved.apiKey, baseUrl: resolved.baseUrl });
1028
+ const dryRun = flags["dry-run"] === true;
1029
+ const results = [];
1030
+ for (const c of targets) {
1031
+ const r = await installInto(c, entry, { dryRun, readFile: readFile2, writeFile: writeFile2, mkdir: mkdir2, copyFile, dirname: dirname2 });
1032
+ results.push({ client: c.id, ...r });
1033
+ }
1034
+ const changed = results.filter((r) => r.action === "added" || r.action === "updated");
1035
+ const lines = results.map((r) => `${pad(r.client, 16)} ${r.action}${r.detail ? ` \u2014 ${r.detail}` : ""}`);
1036
+ if (changed.length > 0 && !dryRun) {
1037
+ lines.push("");
1038
+ lines.push("Restart the client(s) above to pick it up.");
1039
+ lines.push('Then ask: "what did I send recently?" or "did the client download it?"');
1040
+ lines.push("");
1041
+ lines.push(`Your API key is now also in ${changed.length === 1 ? "that config file" : "those config files"}, in plain text.`);
1042
+ }
1043
+ if (changed.length === 0 && !dryRun) {
1044
+ lines.push("");
1045
+ lines.push("Nothing to do. `yungle mcp status` shows where it is installed.");
1046
+ }
1047
+ return out(json, { installed: results, dryRun }, lines.join("\n"));
1048
+ }
1049
+ async function installInto(c, entry, io) {
1050
+ if (!c.configPath) return { action: "skipped", detail: "not available on this platform" };
1051
+ if (c.viaCli) {
1052
+ const { spawnSync } = await import("node:child_process");
1053
+ const probe = spawnSync(c.viaCli.bin, ["--version"], { stdio: "ignore", shell: false });
1054
+ if (probe.error) return { action: "skipped", detail: `${c.viaCli.bin} not installed` };
1055
+ if (io.dryRun) {
1056
+ return { action: "would run", detail: `${c.viaCli.bin} mcp add-json -s user yungle \u2026` };
1057
+ }
1058
+ const existed = spawnSync(c.viaCli.bin, ["mcp", "get", "yungle"], { stdio: "ignore", shell: false }).status === 0;
1059
+ if (existed) {
1060
+ spawnSync(c.viaCli.bin, ["mcp", "remove", "-s", "user", "yungle"], {
1061
+ stdio: "ignore",
1062
+ shell: false
1063
+ });
1064
+ }
1065
+ const res = spawnSync(c.viaCli.bin, c.viaCli.args("yungle", JSON.stringify(entry)), {
1066
+ stdio: "pipe",
1067
+ shell: false,
1068
+ encoding: "utf8"
1069
+ });
1070
+ if (res.status !== 0) {
1071
+ const why = (res.stderr || res.stdout || "").trim().split("\n")[0] || `exit ${res.status}`;
1072
+ return { action: "failed", detail: why };
1073
+ }
1074
+ return {
1075
+ action: existed ? "updated" : "added",
1076
+ detail: `via ${c.viaCli.bin} mcp add-json`
1077
+ };
1078
+ }
1079
+ let raw = null;
1080
+ try {
1081
+ raw = await io.readFile(c.configPath, "utf8");
1082
+ } catch {
1083
+ let dirExists = true;
1084
+ try {
1085
+ await io.readFile(io.dirname(c.configPath), "utf8");
1086
+ } catch (err) {
1087
+ dirExists = err.code === "EISDIR";
1088
+ }
1089
+ if (!dirExists) return { action: "skipped", detail: "not installed" };
1090
+ }
1091
+ let existing = {};
1092
+ if (raw !== null && raw.trim() !== "") {
1093
+ try {
1094
+ existing = JSON.parse(raw);
1095
+ } catch {
1096
+ return {
1097
+ action: "refused",
1098
+ path: c.configPath,
1099
+ detail: `${c.configPath} is not valid JSON \u2014 fix or move it, nothing was written`
1100
+ };
1101
+ }
1102
+ if (existing === null || typeof existing !== "object" || Array.isArray(existing)) {
1103
+ return {
1104
+ action: "refused",
1105
+ path: c.configPath,
1106
+ detail: `${c.configPath} is not a JSON object \u2014 nothing was written`
1107
+ };
1108
+ }
1109
+ }
1110
+ const merged = mergeServerEntry(existing, c.serversKey, "yungle", entry);
1111
+ if (merged.kind === "unchanged") return { action: "already current", path: c.configPath };
1112
+ if (io.dryRun) {
1113
+ return { action: `would be ${merged.kind}`, path: c.configPath, detail: c.configPath };
1114
+ }
1115
+ if (raw !== null) {
1116
+ try {
1117
+ await io.copyFile(c.configPath, `${c.configPath}.yungle-bak`);
1118
+ } catch {
1119
+ }
1120
+ }
1121
+ await io.mkdir(io.dirname(c.configPath), { recursive: true });
1122
+ await io.writeFile(c.configPath, `${JSON.stringify(merged.config, null, 2)}
1123
+ `, { mode: 384 });
1124
+ return { action: merged.kind, path: c.configPath, detail: c.configPath };
1125
+ }
860
1126
  export {
1127
+ HELP,
861
1128
  loadCredentials,
862
1129
  main
863
1130
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yungle-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Send and sync files with Yungle from the command line.",
6
6
  "bin": {