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 +32 -0
- package/dist/yungle.mjs +268 -1
- package/package.json +1 -1
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
|
};
|