onlymcp 0.9.1 → 0.11.0

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.
Files changed (76) hide show
  1. package/README.md +14 -11
  2. package/dist/apiClient.d.ts +129 -7
  3. package/dist/apiClient.js +98 -4
  4. package/dist/apiClient.js.map +1 -1
  5. package/dist/auth/deviceLogin.js +5 -1
  6. package/dist/auth/deviceLogin.js.map +1 -1
  7. package/dist/bin/cli.js +349 -131
  8. package/dist/bin/cli.js.map +1 -1
  9. package/dist/bin/mcp.js +16 -2
  10. package/dist/bin/mcp.js.map +1 -1
  11. package/dist/export/unpack.d.ts +33 -0
  12. package/dist/export/unpack.js +54 -0
  13. package/dist/export/unpack.js.map +1 -0
  14. package/dist/identity.d.ts +25 -0
  15. package/dist/identity.js +106 -0
  16. package/dist/identity.js.map +1 -0
  17. package/dist/import/collect.d.ts +14 -1
  18. package/dist/import/collect.js +25 -3
  19. package/dist/import/collect.js.map +1 -1
  20. package/dist/import/frontmatter.d.ts +31 -5
  21. package/dist/import/frontmatter.js +64 -22
  22. package/dist/import/frontmatter.js.map +1 -1
  23. package/dist/import/plan.d.ts +22 -1
  24. package/dist/import/plan.js +66 -19
  25. package/dist/import/plan.js.map +1 -1
  26. package/dist/import/prune.d.ts +46 -0
  27. package/dist/import/prune.js +61 -0
  28. package/dist/import/prune.js.map +1 -0
  29. package/dist/import/secrets.js +1 -1
  30. package/dist/import/secrets.js.map +1 -1
  31. package/dist/import/upload.js +17 -2
  32. package/dist/import/upload.js.map +1 -1
  33. package/dist/install.d.ts +67 -6
  34. package/dist/install.js +137 -59
  35. package/dist/install.js.map +1 -1
  36. package/dist/instructions.d.ts +3 -1
  37. package/dist/instructions.js +19 -2
  38. package/dist/instructions.js.map +1 -1
  39. package/dist/memorySync.d.ts +28 -0
  40. package/dist/memorySync.js +56 -0
  41. package/dist/memorySync.js.map +1 -0
  42. package/dist/paths.d.ts +12 -0
  43. package/dist/paths.js +34 -8
  44. package/dist/paths.js.map +1 -1
  45. package/dist/registry/caveats.d.ts +17 -0
  46. package/dist/registry/caveats.js +50 -0
  47. package/dist/registry/caveats.js.map +1 -0
  48. package/dist/registry/clients.d.ts +11 -0
  49. package/dist/registry/clients.js +327 -0
  50. package/dist/registry/clients.js.map +1 -0
  51. package/dist/registry/index.d.ts +4 -0
  52. package/dist/registry/index.js +5 -0
  53. package/dist/registry/index.js.map +1 -0
  54. package/dist/registry/servers.d.ts +50 -0
  55. package/dist/registry/servers.js +225 -0
  56. package/dist/registry/servers.js.map +1 -0
  57. package/dist/registry/types.d.ts +54 -0
  58. package/dist/registry/types.js +6 -0
  59. package/dist/registry/types.js.map +1 -0
  60. package/dist/stubs.d.ts +1 -0
  61. package/dist/stubs.js +85 -13
  62. package/dist/stubs.js.map +1 -1
  63. package/dist/tools/getAgentDefinition.js +5 -2
  64. package/dist/tools/getAgentDefinition.js.map +1 -1
  65. package/dist/tools/getSkill.js +1 -1
  66. package/dist/tools/getSkill.js.map +1 -1
  67. package/dist/tools/getSkillFile.js +3 -3
  68. package/dist/tools/getSkillFile.js.map +1 -1
  69. package/dist/tools/memoryGraph.d.ts +69 -0
  70. package/dist/tools/memoryGraph.js +128 -0
  71. package/dist/tools/memoryGraph.js.map +1 -0
  72. package/dist/tools/memoryRead.js +1 -1
  73. package/dist/tools/memoryRead.js.map +1 -1
  74. package/dist/tools/memoryWrite.js +1 -1
  75. package/dist/tools/memoryWrite.js.map +1 -1
  76. package/package.json +5 -4
package/dist/bin/cli.js CHANGED
@@ -5,18 +5,24 @@ import open from 'open';
5
5
  import QRCode from 'qrcode';
6
6
  import { createRequire } from 'node:module';
7
7
  import { homedir } from 'node:os';
8
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { createWriteStream, existsSync, mkdirSync } from 'node:fs';
9
+ import { pipeline } from 'node:stream/promises';
10
+ import { Readable } from 'node:stream';
9
11
  import { dirname, join } from 'node:path';
10
- import { ApiClient } from '../apiClient.js';
12
+ import { ApiClient, ApiError } from '../apiClient.js';
11
13
  import { Keyring } from '../auth/keyring.js';
12
14
  import { pollForToken } from '../auth/deviceLogin.js';
13
15
  import { TtlCache } from '../cache.js';
14
- import { targets } from '../install.js';
16
+ import { planInstall, writeInstallTarget } from '../install.js';
15
17
  import { stubDirs } from '../paths.js';
16
18
  import { writeStubs } from '../stubs.js';
17
19
  import { collect } from '../import/collect.js';
18
20
  import { buildImportPlan } from '../import/plan.js';
19
21
  import { countPlan, runUpload } from '../import/upload.js';
22
+ import { countTargets, isEmpty, planPrune, runPrune } from '../import/prune.js';
23
+ import { syncMemoryToDisk } from '../memorySync.js';
24
+ import { unpackArchive } from '../export/unpack.js';
25
+ import { deviceLabel } from '../identity.js';
20
26
  const baseUrl = process.env.ONLYMCP_API_URL ?? 'https://only-mcp.com';
21
27
  // Die Version kommt aus der package.json und steht nicht ein zweites Mal hier
22
28
  // als Literal: sonst meldet `onlymcp --version` nach der ersten
@@ -39,32 +45,16 @@ function safeOpenUrl(url, expectedOrigin) {
39
45
  return false;
40
46
  }
41
47
  }
42
- // `writeFileSync` allein wuerde ~/.claude.json oder ~/.gemini/settings.json
43
- // komplett ersetzen und damit jede andere dort gespeicherte Einstellung
44
- // wegwerfen. Eine vorhandene, aber kaputte Datei wird nie stillschweigend
45
- // ueberschrieben, lieber ein Fehler als ein Datenverlust.
46
- function mergeJsonMcpServer(path, key, value) {
47
- let existing = {};
48
- if (existsSync(path)) {
49
- const raw = readFileSync(path, 'utf8').trim();
50
- if (raw) {
51
- try {
52
- existing = JSON.parse(raw);
53
- }
54
- catch {
55
- throw new Error(`${path} enthaelt ungueltiges JSON. Nichts geschrieben.`);
56
- }
57
- }
58
- }
59
- const mcpServers = existing.mcpServers ?? {};
60
- existing.mcpServers = { ...mcpServers, [key]: value };
61
- writeFileSync(path, `${JSON.stringify(existing, null, 2)}\n`, 'utf8');
62
- }
63
48
  program
64
49
  .command('login')
65
- .description('Dieses Geraet mit OnlyMCP verbinden')
50
+ .description('Connect this device to OnlyMCP')
66
51
  .action(async () => {
67
52
  intro('OnlyMCP');
53
+ // Einmal ehrlich sagen, was mitgeht, BEVOR es mitgeht. Die beiden Werte
54
+ // stehen nachher in der Geraeteliste dieses Kontos, sonst nirgends - aber
55
+ // wer sie nicht schicken will, soll das erfahren, solange er noch abbrechen
56
+ // kann.
57
+ log.info(`This device will register itself as: ${await deviceLabel()}`);
68
58
  const res = await fetch(`${baseUrl}/api/auth/device/code`, {
69
59
  method: 'POST',
70
60
  headers: { 'content-type': 'application/json' },
@@ -81,99 +71,197 @@ program
81
71
  await open(d.verification_uri_complete).catch(() => { });
82
72
  }
83
73
  const s = spinner();
84
- s.start('Warte auf Bestaetigung');
74
+ s.start('Waiting for confirmation');
85
75
  const token = await pollForToken(baseUrl, d.device_code, d.interval ?? 5);
86
76
  Keyring.save(token);
87
- s.stop('Verbunden');
77
+ s.stop('Connected');
88
78
  const api = new ApiClient(baseUrl, () => Keyring.read());
89
- let counts = { skills: 0, agents: 0, files: 0 };
90
- for (const dir of stubDirs())
91
- counts = await writeStubs(api, dir);
92
- outro(`${counts.skills} Skills, ${counts.agents} Agents, ${counts.files} Dateien bereit`);
79
+ const counts = await writeStubsEverywhere(api);
80
+ outro(`${counts.skills} skills, ${counts.agents} agents, ${counts.files} files ready`);
93
81
  });
82
+ /**
83
+ * Stubs in jeden Ordner, den ein Client auf diesem Geraet liest.
84
+ *
85
+ * Gezaehlt wird ueber alle Ordner zusammen, nicht der letzte: bis Aufgabe 12
86
+ * ueberschrieb die Schleife ihr Ergebnis bei jedem Durchlauf und meldete
87
+ * deshalb die Zahlen des zuletzt beschriebenen Ordners als Gesamtzahl.
88
+ */
89
+ async function writeStubsEverywhere(api) {
90
+ const total = { skills: 0, agents: 0, files: 0, kept: [] };
91
+ for (const dir of stubDirs()) {
92
+ const c = await writeStubs(api, dir);
93
+ total.skills += c.skills;
94
+ total.agents += c.agents;
95
+ total.files += c.files;
96
+ for (const k of c.kept)
97
+ total.kept.push(`${dir}/${k}`);
98
+ }
99
+ return total;
100
+ }
101
+ /**
102
+ * Reports a finished import or sync, for the "last import / last sync" line on the devices page.
103
+ * A report that fails (an older server answering 404, no network, a timeout) prints nothing and
104
+ * changes nothing: the command already succeeded.
105
+ */
106
+ async function reportRun(api, kind, files) {
107
+ try {
108
+ await api.reportRun(kind, files);
109
+ }
110
+ catch {
111
+ /* deliberately silent, see above */
112
+ }
113
+ }
114
+ /**
115
+ * Ends this device's session on the server (next round A6). Clearing only the keyring left the
116
+ * session alive there, where it kept counting as a device until it expired. Best effort: null when
117
+ * the session ended, else what went wrong, and the logout goes on on this machine either way.
118
+ */
119
+ async function endServerSession(token) {
120
+ try {
121
+ await new ApiClient(baseUrl, () => token).signOut();
122
+ return null;
123
+ }
124
+ catch (e) {
125
+ if (e instanceof ApiError && e.status > 0)
126
+ return `HTTP ${e.status}`;
127
+ return e instanceof Error ? e.message : String(e);
128
+ }
129
+ }
94
130
  program
95
131
  .command('logout')
96
- .description('Anmeldung und Cache auf diesem Geraet loeschen')
132
+ .description("End this device's session, then remove its login and cache")
97
133
  .action(async () => {
134
+ // The server first, while the token is still here to name the session by.
135
+ const token = Keyring.read();
136
+ const failure = token ? await endServerSession(token) : null;
98
137
  Keyring.clear();
99
138
  await new TtlCache().clear();
100
- log.success('Abgemeldet');
139
+ if (failure) {
140
+ log.warn(`The session on the server could not be ended (${failure}). It counts as a device until it ` +
141
+ 'expires, or until you remove it in the dashboard under Sessions and devices.');
142
+ log.success('Logged out on this device');
143
+ return;
144
+ }
145
+ log.success('Logged out');
101
146
  });
102
147
  program
103
148
  .command('status')
104
- .description('Zeigt, ob dieses Geraet angemeldet ist')
149
+ .description('Show whether this device is logged in')
105
150
  .action(() => {
106
- log.info(Keyring.read() ? 'angemeldet' : 'nicht angemeldet');
151
+ log.info(Keyring.read() ? 'logged in' : 'not logged in');
107
152
  });
108
153
  program
109
154
  .command('sync')
110
- .description('Stub-Dateien neu schreiben')
111
- .action(async () => {
155
+ .description('Rewrite the stub files')
156
+ .option('--memory <dir>', 'Also write memory as files into this folder')
157
+ .action(async (opts) => {
112
158
  const api = new ApiClient(baseUrl, () => Keyring.read());
113
- let counts = { skills: 0, agents: 0, files: 0 };
114
- for (const dir of stubDirs())
115
- counts = await writeStubs(api, dir);
116
- log.success(`${counts.skills} Skills, ${counts.agents} Agents, ${counts.files} Dateien`);
159
+ const counts = await writeStubsEverywhere(api);
160
+ log.success(`${counts.skills} skills, ${counts.agents} agents, ${counts.files} files`);
161
+ if (counts.kept.length > 0) {
162
+ // Stubs liegen in denselben Ordnern, aus denen importiert wird. Was
163
+ // dort schon stand und kein Stub war, bleibt stehen - aber nicht
164
+ // stillschweigend, sonst sucht jemand einen Stub, der nie geschrieben
165
+ // wurde. Gekuerzt, weil ein Geraet mit 270 eigenen Agent-Dateien sonst
166
+ // 270 Zeilen bekaeme und die Erfolgsmeldung aus dem Bild schoebe.
167
+ log.warn([
168
+ `Left untouched, because something of your own is already there (${counts.kept.length})`,
169
+ ...counts.kept.slice(0, 10).map((k) => ` ${k}`),
170
+ ...(counts.kept.length > 10 ? [` ... and ${counts.kept.length - 10} more`] : []),
171
+ ].join('\n'));
172
+ }
173
+ // Every file this run wrote: the stubs, the skills' companion files, and (below) memory.
174
+ const stubFiles = counts.skills + counts.agents + counts.files;
175
+ if (!opts.memory) {
176
+ await reportRun(api, 'sync', stubFiles);
177
+ return;
178
+ }
179
+ const m = await syncMemoryToDisk(api, opts.memory);
180
+ log.success(`${m.written} memory files to ${opts.memory}`);
181
+ if (m.collisions.length > 0) {
182
+ log.warn([
183
+ 'Team files with a path an existing file of yours already has',
184
+ ...m.collisions.map((p) => ` ${p}`),
185
+ ].join('\n'));
186
+ }
187
+ if (m.refused.length > 0) {
188
+ log.warn(['Paths refused', ...m.refused.map((p) => ` ${p}`)].join('\n'));
189
+ }
190
+ if (m.failures.length > 0) {
191
+ log.error([`${m.failures.length} failed`, ...m.failures.map((f) => ` ${f.what} ${f.code}`)].join('\n'));
192
+ process.exitCode = 1;
193
+ }
194
+ // A sync with failures is not a successful one: nothing is reported for it.
195
+ if (m.failures.length === 0)
196
+ await reportRun(api, 'sync', stubFiles + m.written);
117
197
  });
118
198
  program
119
199
  .command('install')
120
- .description('OnlyMCP in einem MCP-Client eintragen')
121
- .option('--client <id>', 'claude, codex oder gemini')
122
- .option('--print', 'nur ausgeben, nichts schreiben')
200
+ .description('Register OnlyMCP in an MCP client')
201
+ .option('--client <id>', 'claude-code, codex or gemini-cli (aliases: claude, gemini)')
202
+ .option('--all', 'Write every config file that already exists on this machine')
203
+ .option('--print', 'Only print, write nothing')
123
204
  .action((opts) => {
124
205
  // ONLYMCP_HOME ist dieselbe Testschleuse wie in paths.ts: damit trifft
125
206
  // kein Testlauf jemals den echten ~/.codex- oder ~/.claude-Ordner.
126
- const all = targets(process.env.ONLYMCP_HOME ?? homedir());
127
- if (!opts.client) {
128
- log.info(`Bekannte Clients: ${all.map((t) => t.id).join(', ')}`);
129
- log.info('Fuer jeden anderen MCP-Client: --print und den Schnipsel selbst eintragen.');
130
- return;
131
- }
132
- const t = all.find((x) => x.id === opts.client);
133
- if (!t) {
134
- // Kein Raten. Ein falsch geratener Pfad ueberschreibt sonst eine fremde
135
- // Konfigurationsdatei.
136
- log.error(`Unbekannter Client. Bekannt: ${all.map((x) => x.id).join(', ')}`);
137
- process.exitCode = 1;
138
- return;
139
- }
140
- if (opts.print) {
141
- log.info(`${t.label} -> ${t.configPath}`);
142
- console.log(t.snippet);
143
- // Bewusst keine zweite Zeile mit ONLYMCP_STUB_DIRS: die Variable steht
144
- // seit install.ts im Schnipsel selbst. Zwei Wege, dasselbe zu setzen,
145
- // laden dazu ein, beide zu gehen.
146
- return;
147
- }
148
- mkdirSync(dirname(t.configPath), { recursive: true });
149
- try {
150
- if (t.id === 'codex') {
151
- // TOML wird angehaengt, nicht ersetzt. Ein zweiter Lauf haengt sonst
152
- // ein zweites [mcp_servers.onlymcp] an, und zwei gleiche Tabellen
153
- // machen die Datei fuer die meisten TOML-Parser ungueltig.
154
- const already = existsSync(t.configPath) && readFileSync(t.configPath, 'utf8').includes('[mcp_servers.onlymcp]');
155
- if (already) {
156
- log.info(`${t.label} ist bereits eingerichtet: ${t.configPath}`);
157
- return;
207
+ const home = process.env.ONLYMCP_HOME ?? homedir();
208
+ const outcome = planInstall(home, opts);
209
+ /** Writes one target, or only prints it with --print. Shared by --client and --all. */
210
+ function apply(t) {
211
+ if (opts.print) {
212
+ log.info(`${t.name} -> ${t.configPath}`);
213
+ console.log(t.snippet);
214
+ // Bewusst keine zweite Zeile mit ONLYMCP_STUB_DIRS: die Variable steht
215
+ // seit install.ts im Schnipsel selbst. Zwei Wege, dasselbe zu setzen,
216
+ // laden dazu ein, beide zu gehen.
217
+ return;
218
+ }
219
+ try {
220
+ const result = writeInstallTarget(t);
221
+ if (result === 'already-present') {
222
+ log.info(`${t.name} is already set up: ${t.configPath}`);
223
+ }
224
+ else {
225
+ log.success(`${t.name} set up: ${t.configPath}`);
158
226
  }
159
- appendFileSync(t.configPath, `\n${t.snippet}`);
160
227
  }
161
- else {
162
- const parsed = JSON.parse(t.snippet);
163
- const entry = Object.entries(parsed.mcpServers)[0];
164
- if (!entry)
165
- throw new Error(`${t.label}: leerer mcpServers-Schnipsel.`);
166
- mergeJsonMcpServer(t.configPath, entry[0], entry[1]);
228
+ catch (e) {
229
+ log.error(e instanceof Error ? e.message : String(e));
230
+ process.exitCode = 1;
167
231
  }
168
232
  }
169
- catch (e) {
170
- log.error(e instanceof Error ? e.message : String(e));
171
- process.exitCode = 1;
172
- return;
233
+ switch (outcome.kind) {
234
+ case 'conflicting-options':
235
+ log.error('Pass either --client <id> or --all, not both.');
236
+ process.exitCode = 2;
237
+ return;
238
+ case 'missing-client':
239
+ log.info(`Known clients: ${outcome.targets.map((t) => t.id).join(', ')}`);
240
+ log.info('Pass --client <id>, or --all.');
241
+ process.exitCode = 2;
242
+ return;
243
+ case 'unknown-client':
244
+ // Kein Raten. Ein falsch geratener Pfad ueberschreibt sonst eine
245
+ // fremde Konfigurationsdatei.
246
+ log.error(`Unknown client. Known: ${outcome.targets.map((t) => t.id).join(', ')}`);
247
+ process.exitCode = 1;
248
+ return;
249
+ case 'none-present-for-all':
250
+ log.error('No known client is set up on this machine yet.');
251
+ log.info(`Pass --client <id> to write its config anyway. Known: ${outcome.targets.map((t) => t.id).join(', ')}.`);
252
+ process.exitCode = 2;
253
+ return;
254
+ case 'single':
255
+ apply(outcome.target);
256
+ return;
257
+ case 'all':
258
+ for (const t of outcome.present)
259
+ apply(t);
260
+ if (outcome.skipped.length > 0) {
261
+ log.info(`Skipped, not set up on this machine: ${outcome.skipped.map((t) => t.id).join(', ')}`);
262
+ }
263
+ return;
173
264
  }
174
- if (t.stubDir)
175
- mkdirSync(t.stubDir, { recursive: true });
176
- log.success(`${t.label} eingerichtet: ${t.configPath}`);
177
265
  });
178
266
  function formatBytes(bytes) {
179
267
  if (bytes < 1024)
@@ -191,44 +279,54 @@ function printPlan(plan, roots) {
191
279
  const withFiles = plan.skills.filter((s) => s.files.length > 0).length;
192
280
  const fileBytes = plan.skills.reduce((n, s) => n + sumBytes(s.files), 0);
193
281
  if (roots.skills) {
194
- log.message(`Skills ${String(plan.skills.length).padStart(4)} aus ${roots.skills}\n` +
195
- ` ${String(files).padStart(4)} weitere Dateien in ${withFiles} Skills ` +
282
+ log.message(`Skills ${String(plan.skills.length).padStart(4)} from ${roots.skills}\n` +
283
+ ` ${String(files).padStart(4)} more files across ${withFiles} skills ` +
196
284
  `${formatBytes(sumBytes(plan.skills) + fileBytes)}`);
197
285
  }
198
286
  if (roots.agents) {
199
- log.message(`Agents ${String(plan.agents.length).padStart(4)} aus ${roots.agents} ${formatBytes(sumBytes(plan.agents))}`);
287
+ log.message(`Agents ${String(plan.agents.length).padStart(4)} from ${roots.agents} ${formatBytes(sumBytes(plan.agents))}`);
200
288
  }
201
289
  if (roots.memory) {
202
- log.message(`Memory ${String(plan.memory.length).padStart(4)} aus ${roots.memory} ${formatBytes(sumBytes(plan.memory))}`);
290
+ log.message(`Memory ${String(plan.memory.length).padStart(4)} from ${roots.memory} ${formatBytes(sumBytes(plan.memory))}`);
203
291
  }
204
292
  if (plan.skipped.length > 0) {
205
293
  const byReason = new Map();
206
294
  for (const s of plan.skipped)
207
295
  byReason.set(s.reason, (byReason.get(s.reason) ?? 0) + 1);
208
- log.message(['Uebersprungen', ...[...byReason].map(([reason, n]) => ` ${String(n).padStart(4)} ${reason}`)].join('\n'));
296
+ log.message(['Skipped', ...[...byReason].map(([reason, n]) => ` ${String(n).padStart(4)} ${reason}`)].join('\n'));
297
+ }
298
+ if (plan.noDescription.length > 0) {
299
+ // Geht trotzdem hoch. Gemeldet, weil die Liste im Dashboard und die
300
+ // Stubs auf jedem Geraet von der Beschreibung leben und ein Skill ohne
301
+ // sie dort als blosser Name steht.
302
+ log.warn(['Missing a description in the SKILL.md header', ...plan.noDescription.map((n) => ` ${n}`)].join('\n'));
209
303
  }
210
304
  if (plan.flagged.length > 0) {
211
305
  log.warn([
212
- 'Zurueckgehalten wegen moeglicher Zugangsdaten (nichts davon geht raus)',
306
+ 'Held back over possible credentials (none of it left this machine)',
213
307
  ...plan.flagged.flatMap((f) => [
214
308
  ` ${f.relative}`,
215
- ...f.hits.map((h) => ` Zeile ${h.line} Regel ${h.rule} ${h.excerpt}`),
309
+ ...f.hits.map((h) => ` line ${h.line} rule ${h.rule} ${h.excerpt}`),
216
310
  ]),
217
311
  ].join('\n'));
218
312
  }
219
313
  }
220
314
  program
221
315
  .command('import')
222
- .description('Bestehende lokale Skills, Agents und Memory nach OnlyMCP holen')
223
- .option('--skills <dir>', 'Ordner mit Skill-Unterordnern')
224
- .option('--agents <dir>', 'Ordner mit Agent-Dateien')
316
+ .description('Bring existing local skills, agents and memory into OnlyMCP')
317
+ .option('--skills <dir>', 'Folder with skill subfolders, or a plugin cache')
318
+ .option('--agents <dir>', 'Folder with agent files')
225
319
  // --memory hat mit Absicht keine Vorgabe. Skills und Agents liegen an einem
226
320
  // bekannten Pfad, an dem nur Skills und Agents liegen. Ein Memory-Ordner
227
321
  // nicht: eine Vorgabe auf irgendjemandes Notizen zu richten und die
228
322
  // hochzuladen waere das Schlimmste, was dieser Befehl tun koennte.
229
- .option('--memory <dir>', 'Ordner mit Memory-Dateien (keine Vorgabe)')
230
- .option('--dry-run', 'Nur zeigen, was hochginge, und beenden')
231
- .option('--yes', 'Ohne Rueckfrage hochladen')
323
+ .option('--memory <dir>', 'Folder with memory files (no default)')
324
+ .option('--dry-run', 'Only show what would be uploaded, then stop')
325
+ .option('--yes', 'Upload without asking first')
326
+ // --prune loescht im Konto. Es hat deshalb bewusst KEINE Abkuerzung ueber
327
+ // --yes: die Liste wird immer gezeigt und immer einzeln bestaetigt, auch
328
+ // wenn der Upload selbst unbeaufsichtigt laeuft.
329
+ .option('--prune', 'Delete, after confirmation, what the account holds and the source no longer has')
232
330
  .action(async (opts) => {
233
331
  // Dieselbe Testschleuse wie in install.ts und paths.ts.
234
332
  const home = process.env.ONLYMCP_HOME ?? homedir();
@@ -239,7 +337,7 @@ program
239
337
  };
240
338
  intro('OnlyMCP Import');
241
339
  const s = spinner();
242
- s.start('Lese die Ordner');
340
+ s.start('Reading the folders');
243
341
  let plan;
244
342
  try {
245
343
  const [skills, agents, memory] = await Promise.all([
@@ -247,27 +345,29 @@ program
247
345
  existsSync(roots.agents) ? collect(roots.agents) : Promise.resolve([]),
248
346
  roots.memory && existsSync(roots.memory) ? collect(roots.memory) : Promise.resolve([]),
249
347
  ]);
250
- plan = buildImportPlan({ skills, agents, memory });
348
+ plan = buildImportPlan({ skills, agents, memory }, { skillsRoot: roots.skills });
251
349
  }
252
350
  catch (e) {
253
- s.stop('Abgebrochen');
351
+ s.stop('Aborted');
254
352
  log.error(e instanceof Error ? e.message : String(e));
255
353
  process.exitCode = 1;
256
354
  return;
257
355
  }
258
- s.stop('Gelesen');
356
+ s.stop('Read');
259
357
  printPlan(plan, {
260
358
  skills: existsSync(roots.skills) ? roots.skills : undefined,
261
359
  agents: existsSync(roots.agents) ? roots.agents : undefined,
262
360
  memory: roots.memory && existsSync(roots.memory) ? roots.memory : undefined,
263
361
  });
264
362
  const total = countPlan(plan);
265
- if (total === 0) {
266
- outro('Nichts zu tun.');
363
+ // Mit --prune gibt es auch dann etwas zu tun, wenn nichts hochgeht: genau
364
+ // ein leerer Quellordner ist der Fall, in dem am meisten geloescht wuerde.
365
+ if (total === 0 && !opts.prune) {
366
+ outro('Nothing to do.');
267
367
  return;
268
368
  }
269
- if (opts.dryRun) {
270
- outro('Nichts hochgeladen (--dry-run).');
369
+ if (opts.dryRun && !opts.prune) {
370
+ outro('Nothing uploaded (--dry-run).');
271
371
  return;
272
372
  }
273
373
  const api = new ApiClient(baseUrl, () => Keyring.read());
@@ -275,37 +375,37 @@ program
275
375
  // behebender Fehler. Die Gegenmassnahme ist eine Zeile Ausgabe.
276
376
  try {
277
377
  const me = await api.getMe();
278
- log.info(`Ziel ${baseUrl} als ${me.email} Stufe ${me.plan.name}`);
378
+ log.info(`Target ${baseUrl} as ${me.email} plan ${me.plan.name}`);
279
379
  }
280
380
  catch (e) {
281
- log.error(`Nicht angemeldet oder Server nicht erreichbar (${e instanceof Error ? e.message : String(e)}).`);
282
- log.info('Zuerst: onlymcp login');
381
+ log.error(`Not logged in, or the server is unreachable (${e instanceof Error ? e.message : String(e)}).`);
382
+ log.info('First: npx onlymcp login');
283
383
  process.exitCode = 1;
284
384
  return;
285
385
  }
286
- if (!opts.yes) {
386
+ if (total > 0 && !opts.yes && !opts.dryRun) {
287
387
  if (!process.stdin.isTTY) {
288
388
  // Ohne Terminal gibt es keine Rueckfrage, also auch keinen Upload.
289
389
  // Wer das automatisieren will, sagt --yes und hat den Plan vorher mit
290
390
  // --dry-run gesehen.
291
- log.error('Kein Terminal fuer die Rueckfrage. Erst --dry-run ansehen, dann --yes.');
391
+ log.error('No terminal for the prompt. Run --dry-run first, then --yes.');
292
392
  process.exitCode = 1;
293
393
  return;
294
394
  }
295
- const go = await confirm({ message: `${total} Eintraege hochladen?`, initialValue: false });
395
+ const go = await confirm({ message: `Upload ${total} entries?`, initialValue: false });
296
396
  if (isCancel(go) || !go) {
297
- cancel('Abgebrochen. Nichts hochgeladen.');
397
+ cancel('Cancelled. Nothing uploaded.');
298
398
  return;
299
399
  }
300
400
  if (plan.flagged.length > 0) {
301
401
  const release = await multiselect({
302
- message: 'Zurueckgehaltene Dateien trotzdem mitschicken?',
402
+ message: 'Send the held-back files anyway?',
303
403
  options: plan.flagged.map((f) => ({ value: f.relative, label: f.relative })),
304
404
  required: false,
305
405
  initialValues: [],
306
406
  });
307
407
  if (isCancel(release)) {
308
- cancel('Abgebrochen. Nichts hochgeladen.');
408
+ cancel('Cancelled. Nothing uploaded.');
309
409
  return;
310
410
  }
311
411
  if (release.length > 0) {
@@ -323,23 +423,141 @@ program
323
423
  skills: clear(skills),
324
424
  agents: clear(agents),
325
425
  memory: clear(memory),
426
+ }, { skillsRoot: roots.skills });
427
+ }
428
+ }
429
+ }
430
+ if (total > 0 && !opts.dryRun) {
431
+ const up = spinner();
432
+ up.start('Uploading');
433
+ const result = await runUpload(api, plan, (done, all, label) => {
434
+ up.message(`${done}/${all} ${label}`);
435
+ });
436
+ up.stop('Done');
437
+ log.success(`${result.skills} skills, ${result.skillFiles} skill files, ` +
438
+ `${result.agents} agents, ${result.memory} memory files`);
439
+ if (result.failures.length > 0) {
440
+ log.error([`${result.failures.length} failed`, ...result.failures.map((f) => ` ${f.what} ${f.code}`)].join('\n'));
441
+ process.exitCode = 1;
442
+ }
443
+ else {
444
+ await reportRun(api, 'import', result.skills + result.skillFiles + result.agents + result.memory);
445
+ }
446
+ }
447
+ if (opts.prune) {
448
+ // Verglichen wird nur, was auch gelesen wurde. Ohne --memory sagt ein
449
+ // Import gar nichts ueber Memory aus, und "steht im Konto, fehlt in der
450
+ // Quelle" waere dort keine Aussage, sondern ein Unfall.
451
+ const covered = {
452
+ skills: existsSync(roots.skills),
453
+ agents: existsSync(roots.agents),
454
+ memory: Boolean(roots.memory && existsSync(roots.memory)),
455
+ };
456
+ const targets = await planPrune(api, plan, covered);
457
+ if (isEmpty(targets)) {
458
+ log.info('Nothing to delete: the account holds nothing the source does not know.');
459
+ }
460
+ else {
461
+ // Vollstaendig aufgezaehlt, nicht gezaehlt. Eine Zahl zu bestaetigen
462
+ // heisst, etwas zu bestaetigen, das man nicht gesehen hat.
463
+ log.warn([
464
+ 'Would delete (only your own, nothing from a team, nothing archived)',
465
+ ...targets.skills.map((n) => ` Skill ${n}`),
466
+ ...targets.agents.map((n) => ` Agent ${n}`),
467
+ ...targets.memory.map((p) => ` Memory ${p}`),
468
+ ].join('\n'));
469
+ if (opts.dryRun) {
470
+ log.info('Nothing deleted (--dry-run).');
471
+ }
472
+ else if (!process.stdin.isTTY) {
473
+ // Kein --yes-Durchgriff: geloescht wird nur von Hand.
474
+ log.error('No terminal for the prompt. Nothing deleted.');
475
+ process.exitCode = 1;
476
+ }
477
+ else {
478
+ const go = await confirm({
479
+ message: `Delete ${countTargets(targets)} entries in the account?`,
480
+ initialValue: false,
326
481
  });
482
+ if (isCancel(go) || !go) {
483
+ log.info('Nothing deleted.');
484
+ }
485
+ else {
486
+ const pruned = await runPrune(api, targets);
487
+ log.success(`${pruned.deleted} deleted`);
488
+ if (pruned.failures.length > 0) {
489
+ log.error([
490
+ `${pruned.failures.length} failed`,
491
+ ...pruned.failures.map((f) => ` ${f.what} ${f.code}`),
492
+ ].join('\n'));
493
+ process.exitCode = 1;
494
+ }
495
+ }
327
496
  }
328
497
  }
329
498
  }
330
- const up = spinner();
331
- up.start('Lade hoch');
332
- const result = await runUpload(api, plan, (done, all, label) => {
333
- up.message(`${done}/${all} ${label}`);
334
- });
335
- up.stop('Fertig');
336
- log.success(`${result.skills} Skills, ${result.skillFiles} Skill-Dateien, ` +
337
- `${result.agents} Agents, ${result.memory} Memory-Dateien`);
338
- if (result.failures.length > 0) {
339
- log.error([`${result.failures.length} fehlgeschlagen`, ...result.failures.map((f) => ` ${f.what} ${f.code}`)].join('\n'));
499
+ outro(opts.dryRun ? 'Nothing changed (--dry-run).' : 'Then: npx onlymcp sync on every device.');
500
+ });
501
+ program
502
+ .command('export')
503
+ .description('Get everything out of the account: memory, skills, agents, connections')
504
+ .option('--out <dir>', 'Unpack the archive into this folder')
505
+ .option('--zip <file>', 'Keep the archive as a file')
506
+ .option('--scope <scope>', 'personal, all or team:<id>', 'all')
507
+ .option('--types <list>', 'memory,skills,agents,connections')
508
+ .action(async (opts) => {
509
+ // Genau eines von beiden. Beides hiesse zweimal herunterladen, keines
510
+ // hiesse, die teuerste Leseoperation des Produkts ins Nichts zu schicken.
511
+ if ((opts.out ? 1 : 0) + (opts.zip ? 1 : 0) !== 1) {
512
+ log.error('Either --out <folder> or --zip <file>, not both and not neither.');
513
+ process.exitCode = 1;
514
+ return;
515
+ }
516
+ intro('OnlyMCP Export');
517
+ const api = new ApiClient(baseUrl, () => Keyring.read());
518
+ const s = spinner();
519
+ s.start('Fetching the archive');
520
+ let res;
521
+ try {
522
+ res = await api.exportArchive({
523
+ scope: opts.scope,
524
+ types: opts.types?.split(',').map((t) => t.trim()).filter((t) => t !== ''),
525
+ });
526
+ }
527
+ catch (e) {
528
+ s.stop('Aborted');
529
+ log.error(e instanceof Error ? e.message : String(e));
530
+ log.info('Not logged in? First: npx onlymcp login');
531
+ process.exitCode = 1;
532
+ return;
533
+ }
534
+ if (opts.zip) {
535
+ // In die Datei geschrieben, waehrend es ankommt. Ein Konto mit zwei
536
+ // Gigabyte soll nicht erst zwei Gigabyte Arbeitsspeicher kosten.
537
+ mkdirSync(dirname(opts.zip), { recursive: true });
538
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(opts.zip));
539
+ s.stop('Done');
540
+ outro(`Archive: ${opts.zip}`);
541
+ return;
542
+ }
543
+ const bytes = new Uint8Array(await res.arrayBuffer());
544
+ const result = await unpackArchive(bytes, opts.out);
545
+ s.stop('Done');
546
+ log.success(`${result.written} files in ${opts.out}`);
547
+ if (result.refused.length > 0) {
548
+ log.warn(['Not written, path in the archive is not allowed', ...result.refused.map((p) => ` ${p}`)].join('\n'));
549
+ }
550
+ if (result.failed.length > 0) {
551
+ // Auf Windows der Normalfall fuer einen Skill mit Namensraum: der
552
+ // Doppelpunkt ist dort kein zulaessiges Zeichen in einem Ordnernamen.
553
+ log.warn([
554
+ "Not written, the filename doesn't work on this system",
555
+ ...result.failed.map((f) => ` ${f.path} ${f.reason}`),
556
+ 'With --zip <file> the archive stays unchanged.',
557
+ ].join('\n'));
340
558
  process.exitCode = 1;
341
559
  }
342
- outro('Danach: onlymcp sync auf jedem Geraet.');
560
+ outro(`Restore: npx onlymcp import --skills ${opts.out}/skills --agents ${opts.out}/agents --memory ${opts.out}/memory`);
343
561
  });
344
562
  await program.parseAsync();
345
563
  //# sourceMappingURL=cli.js.map