@xmemo/client 0.4.126 → 0.4.128

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 (3) hide show
  1. package/README.md +64 -3
  2. package/package.json +1 -1
  3. package/src/cli.js +491 -22
package/README.md CHANGED
@@ -16,17 +16,31 @@ internal scripts are not part of this npm package.
16
16
  npm install -g @xmemo/client
17
17
  ```
18
18
 
19
+ Upgrade an existing global install:
20
+
21
+ ```bash
22
+ xmemo update
23
+ # or
24
+ xmemo --update
25
+ ```
26
+
27
+ Both commands run `npm install -g @xmemo/client@latest`. Use
28
+ `xmemo update --dry-run` to print the exact command without changing anything.
29
+
19
30
  ## Commands
20
31
 
21
32
  ```bash
33
+ xmemo update
22
34
  xmemo setup codex
23
35
  xmemo setup codex --yes
24
36
  xmemo smoke --client codex
25
37
  xmemo doctor
26
38
  xmemo discovery show
27
39
  xmemo setup
40
+ xmemo login
28
41
  xmemo status
29
42
  xmemo token status
43
+ xmemo token add --from-stdin
30
44
  xmemo env example --shell bash
31
45
  xmemo mcp list
32
46
  xmemo mcp config --client generic
@@ -43,15 +57,40 @@ xmemo privacy
43
57
  token values into project files.
44
58
  - The CLI generates one stable non-secret `XMEMO_AGENT_INSTANCE_ID` per local
45
59
  client profile and stores it in user-scoped config outside git.
46
- - `xmemo token set` refuses plaintext credential storage unless
60
+ - `xmemo login` and `xmemo token add` store tokens only in the user-scoped
61
+ XMemo CLI credential file outside git; token values are never printed.
62
+ - Legacy `xmemo token set` refuses plaintext credential storage unless
47
63
  `--allow-plaintext` is explicitly provided.
48
64
  - The npm package uses a `files` whitelist so only `bin`, `src`, `README.md`,
49
65
  and `LICENSE` are published.
50
66
 
51
67
  ## Token flow
52
68
 
69
+ Recommended personal-user flow:
70
+
71
+ ```bash
72
+ xmemo login
73
+ xmemo token status --verify
74
+ ```
75
+
76
+ `xmemo login` uses the hosted device-login flow when the service advertises it:
77
+ the CLI shows a browser URL and one-time code, the user authorizes in XMemo, and
78
+ the CLI stores the issued MCP token in the user-scoped credential file.
79
+
80
+ Users who already have a token can configure it directly without shell profiles:
81
+
82
+ ```bash
83
+ printf '%s\n' 'your-token' | xmemo token add --from-stdin
84
+ xmemo token status --verify
85
+ ```
86
+
87
+ This is the preferred fallback while a hosted service is rolling out device
88
+ login. It still avoids project files, MCP config files, logs, and chat
89
+ transcripts.
90
+
53
91
  Tokens should be created by the XMemo website or enterprise console, then
54
- stored in a user environment variable or enterprise secret manager:
92
+ stored with `xmemo login`, `xmemo token add`, a user environment variable, or an
93
+ enterprise secret manager:
55
94
 
56
95
  ```bash
57
96
  export XMEMO_KEY="your-token"
@@ -129,13 +168,35 @@ For clients without a verified user-scoped write path, generate a read-only
129
168
  template and apply it manually after review:
130
169
 
131
170
  ```bash
132
- xmemo mcp config --client copilot-cli --base-url "https://your-private-service.example"
171
+ xmemo mcp config --client copilot-cli
133
172
  xmemo mcp config --client generic --base-url "https://your-private-service.example" --json
134
173
  ```
135
174
 
136
175
  Only Codex and Cursor currently have write-capable helpers. Other client writes
137
176
  should only be added after their official user-scoped config format is verified.
138
177
 
178
+ ### Copilot CLI
179
+
180
+ Copilot CLI has `/mcp` management, but it does not currently document a stable
181
+ cross-platform user config file path/format for third-party tools to edit
182
+ directly. The recommended personal-user path is therefore local proxy mode:
183
+
184
+ ```bash
185
+ xmemo login
186
+ xmemo mcp config --client copilot-cli
187
+ xmemo mcp proxy
188
+ ```
189
+
190
+ The generated Copilot CLI template points at `http://127.0.0.1:8765/mcp` and
191
+ does not include token or identity headers. `xmemo mcp proxy` reads the token
192
+ saved by `xmemo login` or `xmemo token add --from-stdin`, adds the XMemo bearer
193
+ token and local agent identity, then forwards requests to `https://xmemo.dev/mcp`.
194
+ If you specifically want the older environment-variable template, run:
195
+
196
+ ```bash
197
+ xmemo mcp config --client copilot-cli --remote-env
198
+ ```
199
+
139
200
  ### Codex
140
201
 
141
202
  Recommended Codex setup:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmemo/client",
3
- "version": "0.4.126",
3
+ "version": "0.4.128",
4
4
  "description": "Privacy-first CLI and MCP setup helper for XMemo.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs/promises';
2
+ import http from 'node:http';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
5
+ import { spawn } from 'node:child_process';
4
6
  import { randomUUID } from 'node:crypto';
5
7
 
6
8
  const PRODUCT_NAME = 'XMemo';
@@ -8,7 +10,7 @@ const PACKAGE_NAME = '@xmemo/client';
8
10
  const FALLBACK_PACKAGE_NAME = '@yonro/xmemo-client';
9
11
  const COMMAND_NAME = 'xmemo';
10
12
  const LEGACY_COMMAND_NAME = 'memory-os';
11
- const CLI_VERSION = '0.4.126';
13
+ const CLI_VERSION = '0.4.128';
12
14
  const DEFAULT_SERVICE_URL = 'https://xmemo.dev';
13
15
  const TOKEN_ENV_VAR = 'XMEMO_KEY';
14
16
  const LEGACY_TOKEN_ENV_VAR = 'MEMORY_OS_MCP_TOKEN';
@@ -20,6 +22,10 @@ const MCP_SERVER_NAME = 'memory_os';
20
22
  const CODEX_PROFILE_TARGET = 'AGENTS.md';
21
23
  const CODEX_PROFILE_MARKER_START = '<!-- memory-os:codex-profile:start -->';
22
24
  const CODEX_PROFILE_MARKER_END = '<!-- memory-os:codex-profile:end -->';
25
+ const DEVICE_LOGIN_START_PATH = '/api/v1/auth/device/start';
26
+ const DEVICE_LOGIN_TOKEN_PATH = '/api/v1/auth/device/token';
27
+ const DEFAULT_PROXY_HOST = '127.0.0.1';
28
+ const DEFAULT_PROXY_PORT = 8765;
23
29
 
24
30
  const MCP_CLIENTS = new Map([
25
31
  ['codex', {
@@ -59,6 +65,10 @@ export async function run(args, io = defaultIo()) {
59
65
  return 0;
60
66
  }
61
67
 
68
+ if (command === 'update' || command === '--update') {
69
+ return await updateCommand(args.slice(1), io);
70
+ }
71
+
62
72
  if (command === 'doctor') {
63
73
  return await doctorCommand(args.slice(1), io);
64
74
  }
@@ -75,6 +85,10 @@ export async function run(args, io = defaultIo()) {
75
85
  return await setupCommand(args.slice(1), io);
76
86
  }
77
87
 
88
+ if (command === 'login') {
89
+ return await loginCommand(args.slice(1), io);
90
+ }
91
+
78
92
  if (command === 'token') {
79
93
  return await tokenCommand(args.slice(1), io);
80
94
  }
@@ -119,7 +133,8 @@ function defaultIo() {
119
133
  stdin: process.stdin,
120
134
  stdout: process.stdout,
121
135
  stderr: process.stderr,
122
- fetch: globalThis.fetch
136
+ fetch: globalThis.fetch,
137
+ spawn
123
138
  };
124
139
  }
125
140
 
@@ -128,14 +143,19 @@ function writeHelp(io) {
128
143
  writeLine(io.stdout, `Fallback npm package: ${FALLBACK_PACKAGE_NAME}; legacy command alias: ${LEGACY_COMMAND_NAME}`);
129
144
  writeLine(io.stdout, '');
130
145
  writeLine(io.stdout, 'Usage:');
146
+ writeLine(io.stdout, ` ${COMMAND_NAME} update [--dry-run] [--json]`);
147
+ writeLine(io.stdout, ` ${COMMAND_NAME} --update [--dry-run] [--json]`);
131
148
  writeLine(io.stdout, ` ${COMMAND_NAME} doctor [--base-url <https://api.example.com>] [--json]`);
132
149
  writeLine(io.stdout, ` ${COMMAND_NAME} discovery show [--base-url <https://api.example.com>] [--json]`);
133
150
  writeLine(io.stdout, ` ${COMMAND_NAME} setup [codex|cursor] [--url <https://api.example.com>] [--write|--yes] [--json]`);
151
+ writeLine(io.stdout, ` ${COMMAND_NAME} login [--from-stdin] [--base-url <url>] [--json]`);
134
152
  writeLine(io.stdout, ` ${COMMAND_NAME} status [--url <https://api.example.com>] [--json]`);
135
- writeLine(io.stdout, ` ${COMMAND_NAME} token status`);
153
+ writeLine(io.stdout, ` ${COMMAND_NAME} token status [--verify]`);
154
+ writeLine(io.stdout, ` ${COMMAND_NAME} token add --from-stdin`);
136
155
  writeLine(io.stdout, ` ${COMMAND_NAME} token set --from-stdin [--allow-plaintext]`);
137
156
  writeLine(io.stdout, ` ${COMMAND_NAME} mcp list`);
138
- writeLine(io.stdout, ` ${COMMAND_NAME} mcp config --client <codex|cursor|generic> [--base-url <url>] [--json]`);
157
+ writeLine(io.stdout, ` ${COMMAND_NAME} mcp config --client <codex|cursor|copilot-cli|generic> [--base-url <url>] [--json]`);
158
+ writeLine(io.stdout, ` ${COMMAND_NAME} mcp proxy [--port ${DEFAULT_PROXY_PORT}]`);
139
159
  writeLine(io.stdout, ` ${COMMAND_NAME} mcp profile codex [--json]`);
140
160
  writeLine(io.stdout, ` ${COMMAND_NAME} profile install codex [--target AGENTS.md] [--dry-run|--json]`);
141
161
  writeLine(io.stdout, ` ${COMMAND_NAME} profile uninstall codex [--target AGENTS.md] [--json]`);
@@ -147,6 +167,50 @@ function writeHelp(io) {
147
167
  writeLine(io.stdout, `Default service URL: ${DEFAULT_SERVICE_URL}; use --url or XMEMO_URL for private deployments.`);
148
168
  writeLine(io.stdout, '');
149
169
  writeLine(io.stdout, 'Privacy defaults: no telemetry, no token in project files, and no token is sent by `status`, `doctor`, or `discovery`.');
170
+ writeLine(io.stdout, '`login` and `token add` store credentials only in the user-scoped XMemo CLI config directory.');
171
+ }
172
+
173
+ async function updateCommand(args, io) {
174
+ const outputJson = hasFlag(args, '--json');
175
+ const dryRun = hasFlag(args, '--dry-run');
176
+ const npmCommand = npmExecutable();
177
+ const npmArgs = ['install', '-g', `${PACKAGE_NAME}@latest`];
178
+ const report = {
179
+ package: PACKAGE_NAME,
180
+ command: [npmCommand, ...npmArgs],
181
+ dryRun,
182
+ tokenSent: false,
183
+ projectFilesModified: false
184
+ };
185
+
186
+ if (dryRun) {
187
+ if (outputJson) {
188
+ writeLine(io.stdout, JSON.stringify(report, null, 2));
189
+ } else {
190
+ writeLine(io.stdout, `Update command: ${report.command.join(' ')}`);
191
+ writeLine(io.stdout, 'Dry run only; no changes made.');
192
+ }
193
+ return 0;
194
+ }
195
+
196
+ if (!outputJson) {
197
+ writeLine(io.stdout, `Updating ${PACKAGE_NAME} with: ${report.command.join(' ')}`);
198
+ }
199
+ const result = await runProcess(npmCommand, npmArgs, io, { stream: !outputJson });
200
+ report.exitCode = result.code;
201
+ report.completed = result.code === 0;
202
+
203
+ if (outputJson) {
204
+ writeLine(io.stdout, JSON.stringify(report, null, 2));
205
+ }
206
+ if (result.code !== 0) {
207
+ const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
208
+ throw new UsageError(`Update failed: ${detail}`);
209
+ }
210
+ if (!outputJson) {
211
+ writeLine(io.stdout, `Update complete. Run \`${COMMAND_NAME} --version\` to confirm.`);
212
+ }
213
+ return 0;
150
214
  }
151
215
 
152
216
  async function doctorCommand(args, io) {
@@ -385,46 +449,119 @@ async function profileCommand(args, io) {
385
449
  return 0;
386
450
  }
387
451
 
452
+ async function loginCommand(args, io) {
453
+ const outputJson = hasFlag(args, '--json');
454
+ const fromStdin = hasFlag(args, '--from-stdin') || hasFlag(args, '--token-stdin');
455
+ const baseUrl = normalizeBaseUrl(baseUrlOption(args, io.env));
456
+ const timeoutMs = parsePositiveInteger(optionValue(args, '--timeout-ms') ?? '30000', '--timeout-ms');
457
+ const pollOnce = hasFlag(args, '--poll-once');
458
+
459
+ if (fromStdin) {
460
+ const result = await storeTokenFromStdin(io, { source: 'stdin' });
461
+ if (outputJson) {
462
+ writeLine(io.stdout, JSON.stringify(result, null, 2));
463
+ } else {
464
+ writeLine(io.stdout, `${PRODUCT_NAME} login complete.`);
465
+ writeLine(io.stdout, `Stored token in user-scoped credential file: ${result.credentialPath}`);
466
+ writeLine(io.stdout, 'Token value was not printed. Project files were not modified.');
467
+ }
468
+ return 0;
469
+ }
470
+
471
+ const start = await startDeviceLogin(baseUrl, timeoutMs, io);
472
+ if (!outputJson) {
473
+ writeLine(io.stdout, `${PRODUCT_NAME} device login`);
474
+ writeLine(io.stdout, `Open: ${start.verificationUriComplete ?? start.verificationUri}`);
475
+ if (start.userCode) {
476
+ writeLine(io.stdout, `Code: ${start.userCode}`);
477
+ }
478
+ writeLine(io.stdout, 'Waiting for authorization...');
479
+ }
480
+
481
+ const token = await pollDeviceLogin(baseUrl, start, timeoutMs, io, { pollOnce });
482
+ const result = await storeTokenValue(token, { source: 'device-login' }, io.env);
483
+ const payload = {
484
+ ...result,
485
+ baseUrl,
486
+ verificationUri: start.verificationUri,
487
+ deviceLogin: true
488
+ };
489
+
490
+ if (outputJson) {
491
+ writeLine(io.stdout, JSON.stringify(payload, null, 2));
492
+ } else {
493
+ writeLine(io.stdout, 'Login complete. Token stored securely in the user-scoped XMemo CLI config directory.');
494
+ writeLine(io.stdout, `Credential path: ${result.credentialPath}`);
495
+ writeLine(io.stdout, `Verify with: ${COMMAND_NAME} token status --verify`);
496
+ }
497
+ return 0;
498
+ }
499
+
388
500
  async function tokenCommand(args, io) {
389
501
  const subcommand = args[0] ?? 'help';
390
502
 
391
503
  if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
392
504
  writeLine(io.stdout, 'Token commands:');
393
- writeLine(io.stdout, ` ${COMMAND_NAME} token status`);
505
+ writeLine(io.stdout, ` ${COMMAND_NAME} token status [--verify]`);
506
+ writeLine(io.stdout, ` ${COMMAND_NAME} token add --from-stdin`);
394
507
  writeLine(io.stdout, ` ${COMMAND_NAME} token set --from-stdin [--allow-plaintext]`);
395
508
  writeLine(io.stdout, '');
396
- writeLine(io.stdout, `Preferred enterprise path: set ${TOKEN_ENV_VAR} in your user or secret manager environment.`);
509
+ writeLine(io.stdout, `${COMMAND_NAME} login is the recommended personal-user path.`);
510
+ writeLine(io.stdout, `${COMMAND_NAME} token add --from-stdin stores a token in the user-scoped XMemo CLI config directory.`);
397
511
  return 0;
398
512
  }
399
513
 
400
514
  if (subcommand === 'status') {
401
- const credentialPath = credentialsPath(io.env);
515
+ const credential = await readStoredCredential(io.env);
402
516
  const hasEnvironmentToken = Boolean(io.env[TOKEN_ENV_VAR] ?? io.env[LEGACY_TOKEN_ENV_VAR]);
403
- const hasPlaintextCredential = await fileExists(credentialPath);
517
+ const hasUserCredential = Boolean(credential.token);
404
518
  writeLine(io.stdout, `Environment token: ${hasEnvironmentToken ? 'present' : 'missing'} (${TOKEN_ENV_VAR})`);
405
- writeLine(io.stdout, `User credential file: ${hasPlaintextCredential ? 'present' : 'missing'} (${credentialPath})`);
519
+ writeLine(io.stdout, `User credential file: ${hasUserCredential ? 'present' : 'missing'} (${credential.path})`);
406
520
  writeLine(io.stdout, 'Token values are never printed.');
407
- return hasEnvironmentToken || hasPlaintextCredential ? 0 : 1;
521
+ if (hasFlag(args, '--verify')) {
522
+ const token = await resolveCredentialToken(io.env);
523
+ if (!token) {
524
+ writeLine(io.stderr, `No token found. Run \`${COMMAND_NAME} login\` or \`${COMMAND_NAME} token add --from-stdin\`.`);
525
+ return 1;
526
+ }
527
+ const baseUrl = normalizeBaseUrl(baseUrlOption(args, io.env));
528
+ const timeoutMs = parsePositiveInteger(optionValue(args, '--timeout-ms') ?? '10000', '--timeout-ms');
529
+ const verification = await verifyTokenWithMcp(baseUrl, token, timeoutMs, io);
530
+ writeLine(io.stdout, `Remote token verification: ${verification.ok ? 'ok' : 'failed'} (${verification.detail})`);
531
+ return verification.ok ? 0 : 1;
532
+ }
533
+ return hasEnvironmentToken || hasUserCredential ? 0 : 1;
408
534
  }
409
535
 
410
- if (subcommand === 'set') {
536
+ if (subcommand === 'add') {
411
537
  if (!hasFlag(args, '--from-stdin')) {
412
538
  throw new UsageError('Refusing command-line token input. Pipe the token through stdin with --from-stdin.');
413
539
  }
540
+ const result = await storeTokenFromStdin(io, { source: 'token-add' });
541
+ if (hasFlag(args, '--json')) {
542
+ writeLine(io.stdout, JSON.stringify(result, null, 2));
543
+ } else {
544
+ writeLine(io.stdout, `Stored token in user-scoped credential file: ${result.credentialPath}`);
545
+ writeLine(io.stdout, 'Token value was not printed. Project files were not modified.');
546
+ }
547
+ return 0;
548
+ }
414
549
 
550
+ if (subcommand === 'set') {
551
+ if (!hasFlag(args, '--from-stdin')) {
552
+ throw new UsageError('Refusing command-line token input. Pipe the token through stdin with --from-stdin.');
553
+ }
415
554
  const token = (await readAll(io.stdin)).trim();
416
555
  validateToken(token);
417
-
418
556
  if (!hasFlag(args, '--allow-plaintext')) {
419
557
  writeLine(io.stderr, 'Token was read from stdin but was not stored.');
420
558
  writeLine(io.stderr, 'Enterprise default refuses plaintext token storage without --allow-plaintext.');
421
- writeLine(io.stderr, `Preferred: store the token in ${TOKEN_ENV_VAR} via your OS, shell profile, CI secret, or enterprise secret manager.`);
559
+ writeLine(io.stderr, `Preferred personal-user path: ${COMMAND_NAME} login or ${COMMAND_NAME} token add --from-stdin.`);
422
560
  return 2;
423
561
  }
424
562
 
425
- const credentialPath = credentialsPath(io.env);
426
- await writePlaintextCredential(credentialPath, token);
427
- writeLine(io.stdout, `Stored token in user-scoped credential file: ${credentialPath}`);
563
+ const result = await storeTokenValue(token, { source: 'token-set' }, io.env);
564
+ writeLine(io.stdout, `Stored token in user-scoped credential file: ${result.credentialPath}`);
428
565
  writeLine(io.stdout, 'Token value was not printed. Do not commit this file.');
429
566
  return 0;
430
567
  }
@@ -438,7 +575,8 @@ async function mcpCommand(args, io) {
438
575
  if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
439
576
  writeLine(io.stdout, 'MCP commands:');
440
577
  writeLine(io.stdout, ` ${COMMAND_NAME} mcp list`);
441
- writeLine(io.stdout, ` ${COMMAND_NAME} mcp config --client <codex|cursor|generic> [--base-url <url>] [--json]`);
578
+ writeLine(io.stdout, ` ${COMMAND_NAME} mcp config --client <codex|cursor|copilot-cli|generic> [--base-url <url>] [--json]`);
579
+ writeLine(io.stdout, ` ${COMMAND_NAME} mcp proxy [--port ${DEFAULT_PROXY_PORT}] [--base-url <url>]`);
442
580
  writeLine(io.stdout, ` ${COMMAND_NAME} mcp profile codex [--json]`);
443
581
  writeLine(io.stdout, ` ${COMMAND_NAME} mcp add <codex|cursor> [--url <https://api.example.com>]`);
444
582
  writeLine(io.stdout, ` ${COMMAND_NAME} mcp add <codex|cursor> [--url <https://api.example.com>] --write [--config <path>]`);
@@ -463,7 +601,12 @@ async function mcpCommand(args, io) {
463
601
  const clientId = optionValue(args, '--client') ?? args[1] ?? 'generic';
464
602
  const baseUrl = normalizeBaseUrl(baseUrlOption(args, io.env));
465
603
  const mcpUrl = endpointUrl(baseUrl, '/mcp');
466
- const template = mcpConfigTemplate(clientId, mcpUrl);
604
+ const useLocalProxy = clientId === 'copilot-cli' && !hasFlag(args, '--remote-env');
605
+ const proxyPort = parsePositiveInteger(optionValue(args, '--port') ?? String(DEFAULT_PROXY_PORT), '--port');
606
+ const proxyUrl = `http://${DEFAULT_PROXY_HOST}:${proxyPort}/mcp`;
607
+ const template = useLocalProxy
608
+ ? mcpLocalProxyTemplate(clientId, proxyUrl)
609
+ : mcpConfigTemplate(clientId, mcpUrl);
467
610
 
468
611
  if (hasFlag(args, '--json')) {
469
612
  writeLine(io.stdout, JSON.stringify(template, null, 2));
@@ -471,7 +614,12 @@ async function mcpCommand(args, io) {
471
614
  }
472
615
 
473
616
  writeLine(io.stdout, `${PRODUCT_NAME} MCP config template for ${clientId}`);
474
- writeLine(io.stdout, `Requires env: ${TOKEN_ENV_VAR}`);
617
+ if (useLocalProxy) {
618
+ writeLine(io.stdout, `Requires credential: ${COMMAND_NAME} login or ${COMMAND_NAME} token add --from-stdin`);
619
+ writeLine(io.stdout, `Run local proxy: ${template.requiresLocalCommand}`);
620
+ } else {
621
+ writeLine(io.stdout, `Requires env: ${TOKEN_ENV_VAR}`);
622
+ }
475
623
  if (typeof template.snippet === 'string') {
476
624
  writeLine(io.stdout, template.snippet.trimEnd());
477
625
  } else {
@@ -481,6 +629,10 @@ async function mcpCommand(args, io) {
481
629
  return 0;
482
630
  }
483
631
 
632
+ if (subcommand === 'proxy') {
633
+ return await mcpProxyCommand(args.slice(1), io);
634
+ }
635
+
484
636
  if (subcommand === 'profile') {
485
637
  const clientId = args[1] ?? 'codex';
486
638
  if (clientId !== 'codex') {
@@ -544,6 +696,82 @@ async function mcpCommand(args, io) {
544
696
  return 0;
545
697
  }
546
698
 
699
+ async function mcpProxyCommand(args, io) {
700
+ const baseUrl = normalizeBaseUrl(baseUrlOption(args, io.env));
701
+ const mcpUrl = endpointUrl(baseUrl, '/mcp');
702
+ const host = optionValue(args, '--host') ?? DEFAULT_PROXY_HOST;
703
+ const port = parsePositiveInteger(optionValue(args, '--port') ?? String(DEFAULT_PROXY_PORT), '--port');
704
+ const token = await resolveCredentialToken(io.env);
705
+ if (!token) {
706
+ throw new UsageError(`No token found. Run \`${COMMAND_NAME} login\` or \`${COMMAND_NAME} token add --from-stdin\` first.`);
707
+ }
708
+ validateToken(token);
709
+ const identity = await agentIdentity('copilot-cli', io.env);
710
+
711
+ const server = http.createServer(async (request, response) => {
712
+ try {
713
+ await handleMcpProxyRequest({ request, response, mcpUrl, token, identity, io });
714
+ } catch (error) {
715
+ response.statusCode = 502;
716
+ response.setHeader('content-type', 'application/json');
717
+ response.end(JSON.stringify({ error: 'mcp_proxy_error', message: error.message }));
718
+ }
719
+ });
720
+
721
+ await new Promise((resolve, reject) => {
722
+ server.once('error', reject);
723
+ server.listen(port, host, () => {
724
+ server.off('error', reject);
725
+ resolve();
726
+ });
727
+ });
728
+
729
+ writeLine(io.stdout, `${PRODUCT_NAME} MCP proxy listening on http://${host}:${port}/mcp`);
730
+ writeLine(io.stdout, `Forwarding to ${mcpUrl}`);
731
+ writeLine(io.stdout, `Credential source: ${TOKEN_ENV_VAR} or ${credentialsPath(io.env)}`);
732
+ return 0;
733
+ }
734
+
735
+ async function handleMcpProxyRequest({ request, response, mcpUrl, token, identity, io }) {
736
+ const requestUrl = new URL(request.url ?? '/', `http://${request.headers.host ?? `${DEFAULT_PROXY_HOST}:${DEFAULT_PROXY_PORT}`}`);
737
+ if (request.method !== 'POST' || requestUrl.pathname !== '/mcp') {
738
+ response.statusCode = 404;
739
+ response.setHeader('content-type', 'application/json');
740
+ response.end(JSON.stringify({ error: 'not_found' }));
741
+ return;
742
+ }
743
+
744
+ const body = await readAll(request);
745
+ const upstreamHeaders = {
746
+ accept: String(request.headers.accept || 'application/json, text/event-stream'),
747
+ 'content-type': String(request.headers['content-type'] || 'application/json'),
748
+ authorization: `Bearer ${token}`,
749
+ [AGENT_ID_HEADER]: identity.agentId,
750
+ [AGENT_INSTANCE_HEADER]: identity.agentInstanceId,
751
+ 'user-agent': `XMemo-CLI-Proxy/${CLI_VERSION} (+https://github.com/yonro/memory-os-cli)`
752
+ };
753
+ const sessionId = request.headers['mcp-session-id'];
754
+ if (sessionId) {
755
+ upstreamHeaders['mcp-session-id'] = Array.isArray(sessionId) ? sessionId[0] : sessionId;
756
+ }
757
+
758
+ const upstream = await io.fetch(mcpUrl, {
759
+ method: 'POST',
760
+ headers: upstreamHeaders,
761
+ body
762
+ });
763
+
764
+ response.statusCode = upstream.status;
765
+ for (const header of ['content-type', 'mcp-session-id']) {
766
+ const value = upstream.headers.get(header);
767
+ if (value) {
768
+ response.setHeader(header, value);
769
+ }
770
+ }
771
+ const buffer = Buffer.from(await upstream.arrayBuffer());
772
+ response.end(buffer);
773
+ }
774
+
547
775
  async function smokeCommand(args, io) {
548
776
  const clientId = optionValue(args, '--client');
549
777
  const outputJson = hasFlag(args, '--json');
@@ -636,10 +864,145 @@ function writePrivacy(io) {
636
864
  writeLine(io.stdout, '- `status` does not send tokens.');
637
865
  writeLine(io.stdout, `- MCP configs reference ${TOKEN_ENV_VAR}; token values are not embedded.`);
638
866
  writeLine(io.stdout, `- Agent instance IDs are non-secret and stored in user-scoped config outside git.`);
639
- writeLine(io.stdout, '- Plaintext token storage requires explicit --allow-plaintext.');
867
+ writeLine(io.stdout, '- `login` and `token add` store credentials in the user-scoped XMemo CLI config directory.');
868
+ writeLine(io.stdout, '- Legacy `token set` plaintext storage requires explicit --allow-plaintext.');
640
869
  writeLine(io.stdout, '- npm publishing is restricted by package.json files whitelist.');
641
870
  }
642
871
 
872
+ async function startDeviceLogin(baseUrl, timeoutMs, io) {
873
+ const payload = await postJson(endpointUrl(baseUrl, DEVICE_LOGIN_START_PATH), {
874
+ client_id: PACKAGE_NAME,
875
+ cli_version: CLI_VERSION,
876
+ token_type: 'mcp_token',
877
+ scopes: ['memory:read', 'memory:write']
878
+ }, timeoutMs, io);
879
+
880
+ const deviceCode = stringValue(payload, ['device_code']);
881
+ const verificationUri = stringValue(payload, ['verification_uri']);
882
+ if (!deviceCode || !verificationUri) {
883
+ throw new UsageError(`Device login did not return device_code and verification_uri from ${baseUrl}.`);
884
+ }
885
+
886
+ return {
887
+ deviceCode,
888
+ userCode: stringValue(payload, ['user_code']),
889
+ verificationUri,
890
+ verificationUriComplete: stringValue(payload, ['verification_uri_complete']),
891
+ expiresIn: Number.isFinite(Number(payload.expires_in)) ? Number(payload.expires_in) : 600,
892
+ interval: Number.isFinite(Number(payload.interval)) ? Math.max(1, Number(payload.interval)) : 5
893
+ };
894
+ }
895
+
896
+ async function pollDeviceLogin(baseUrl, start, timeoutMs, io, options = {}) {
897
+ const deadline = Date.now() + Math.min(start.expiresIn * 1000, timeoutMs);
898
+ while (Date.now() <= deadline) {
899
+ const payload = await postJson(endpointUrl(baseUrl, DEVICE_LOGIN_TOKEN_PATH), {
900
+ device_code: start.deviceCode,
901
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
902
+ }, timeoutMs, io, { allowDevicePending: true });
903
+
904
+ const token = stringValue(payload, ['access_token']) ?? stringValue(payload, ['token']);
905
+ if (token) {
906
+ validateToken(token);
907
+ return token;
908
+ }
909
+
910
+ const error = stringValue(payload, ['error']);
911
+ if (error && error !== 'authorization_pending' && error !== 'slow_down') {
912
+ throw new UsageError(`Device login failed: ${error}`);
913
+ }
914
+ if (options.pollOnce) {
915
+ throw new UsageError('Device login is still pending.');
916
+ }
917
+ await sleep((error === 'slow_down' ? start.interval + 5 : start.interval) * 1000);
918
+ }
919
+
920
+ throw new UsageError('Device login expired before authorization completed.');
921
+ }
922
+
923
+ async function storeTokenFromStdin(io, metadata = {}) {
924
+ const token = (await readAll(io.stdin)).trim();
925
+ validateToken(token);
926
+ return await storeTokenValue(token, metadata, io.env);
927
+ }
928
+
929
+ async function storeTokenValue(token, metadata, env) {
930
+ validateToken(token);
931
+ const credentialPath = credentialsPath(env);
932
+ await writePlaintextCredential(credentialPath, token, metadata);
933
+ return {
934
+ ok: true,
935
+ credentialPath,
936
+ tokenPresent: true,
937
+ tokenPrinted: false,
938
+ projectFilesModified: false,
939
+ storage: 'user-scoped-credential-file'
940
+ };
941
+ }
942
+
943
+ async function readStoredCredential(env) {
944
+ const credentialPath = credentialsPath(env);
945
+ const content = await readTextIfExists(credentialPath);
946
+ if (!content.trim()) {
947
+ return { path: credentialPath, token: null };
948
+ }
949
+
950
+ const parsed = parseJsonConfig(content, credentialPath);
951
+ return {
952
+ path: credentialPath,
953
+ token: stringValue(parsed, ['token']),
954
+ storage: stringValue(parsed, ['storage'])
955
+ };
956
+ }
957
+
958
+ async function resolveCredentialToken(env) {
959
+ const environmentToken = env[TOKEN_ENV_VAR] ?? env[LEGACY_TOKEN_ENV_VAR];
960
+ if (environmentToken) {
961
+ return environmentToken;
962
+ }
963
+ const credential = await readStoredCredential(env);
964
+ return credential.token;
965
+ }
966
+
967
+ async function verifyTokenWithMcp(baseUrl, token, timeoutMs, io) {
968
+ const url = endpointUrl(baseUrl, '/mcp');
969
+ const controller = new AbortController();
970
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
971
+ try {
972
+ const response = await io.fetch(url, {
973
+ method: 'POST',
974
+ headers: {
975
+ accept: 'application/json, text/event-stream',
976
+ 'content-type': 'application/json',
977
+ authorization: `Bearer ${token}`,
978
+ 'user-agent': `XMemo-CLI/${CLI_VERSION} (+https://github.com/yonro/memory-os-cli)`
979
+ },
980
+ body: JSON.stringify({
981
+ jsonrpc: '2.0',
982
+ id: 1,
983
+ method: 'initialize',
984
+ params: {
985
+ protocolVersion: '2024-11-05',
986
+ capabilities: {},
987
+ clientInfo: { name: COMMAND_NAME, version: CLI_VERSION }
988
+ }
989
+ }),
990
+ signal: controller.signal
991
+ });
992
+ return {
993
+ ok: response.ok,
994
+ detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}`
995
+ };
996
+ } catch (error) {
997
+ return {
998
+ ok: false,
999
+ detail: error.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : error.message
1000
+ };
1001
+ } finally {
1002
+ clearTimeout(timeout);
1003
+ }
1004
+ }
1005
+
643
1006
  async function probe(url, timeoutMs, io) {
644
1007
  if (typeof io.fetch !== 'function') {
645
1008
  return { url, ok: false, error: 'fetch unavailable in this Node runtime' };
@@ -693,6 +1056,44 @@ async function fetchJson(url, timeoutMs, io) {
693
1056
  }
694
1057
  }
695
1058
 
1059
+ async function postJson(url, payload, timeoutMs, io, options = {}) {
1060
+ if (typeof io.fetch !== 'function') {
1061
+ throw new UsageError('This Node runtime does not provide fetch; use Node.js 20 or newer.');
1062
+ }
1063
+
1064
+ const controller = new AbortController();
1065
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
1066
+
1067
+ try {
1068
+ const response = await io.fetch(url, {
1069
+ method: 'POST',
1070
+ headers: {
1071
+ accept: 'application/json',
1072
+ 'content-type': 'application/json'
1073
+ },
1074
+ body: JSON.stringify(payload),
1075
+ signal: controller.signal
1076
+ });
1077
+ const responsePayload = await response.json();
1078
+ if (!response.ok) {
1079
+ const error = stringValue(responsePayload, ['error']) ?? stringValue(responsePayload, ['detail']) ?? `HTTP ${response.status}`;
1080
+ if (options.allowDevicePending && (error === 'authorization_pending' || error === 'slow_down')) {
1081
+ return { error };
1082
+ }
1083
+ throw new UsageError(`Request failed with HTTP ${response.status}: ${url} (${error})`);
1084
+ }
1085
+ return responsePayload;
1086
+ } catch (error) {
1087
+ if (error instanceof UsageError) {
1088
+ throw error;
1089
+ }
1090
+ const reason = error.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : error.message;
1091
+ throw new UsageError(`Request failed: ${url} (${reason})`);
1092
+ } finally {
1093
+ clearTimeout(timeout);
1094
+ }
1095
+ }
1096
+
696
1097
  function ensureDiscoveryService(discovery, discoveryUrl) {
697
1098
  const service = stringValue(discovery, ['service']);
698
1099
  if (service && service !== 'memory-os') {
@@ -824,6 +1225,32 @@ function mcpConfigTemplate(clientId, mcpUrl) {
824
1225
  };
825
1226
  }
826
1227
 
1228
+ function mcpLocalProxyTemplate(clientId, proxyUrl) {
1229
+ const serverName = clientId === 'cursor' || clientId === 'gemini-cli' ? 'memory_os' : 'memory-os';
1230
+ return {
1231
+ client: clientId,
1232
+ serverName,
1233
+ snippetFormat: 'json',
1234
+ snippet: {
1235
+ mcpServers: {
1236
+ [serverName]: {
1237
+ type: 'http',
1238
+ url: proxyUrl
1239
+ }
1240
+ }
1241
+ },
1242
+ requiresCredential: [`${COMMAND_NAME} login`, `${COMMAND_NAME} token add --from-stdin`],
1243
+ requiresLocalCommand: `${COMMAND_NAME} mcp proxy --port ${new URL(proxyUrl).port || DEFAULT_PROXY_PORT}`,
1244
+ agentIdentity: {
1245
+ agentId: clientId,
1246
+ agentIdHeader: AGENT_ID_HEADER,
1247
+ agentInstanceEnvVar: AGENT_INSTANCE_ENV_VAR,
1248
+ agentInstanceHeader: AGENT_INSTANCE_HEADER
1249
+ },
1250
+ writesTokenValue: false
1251
+ };
1252
+ }
1253
+
827
1254
  function sameMajorMinor(left, right) {
828
1255
  const leftParts = left.split('.');
829
1256
  const rightParts = right.split('.');
@@ -1386,13 +1813,15 @@ function defaultCursorConfigPath(env) {
1386
1813
  return path.join(home, '.cursor', 'mcp.json');
1387
1814
  }
1388
1815
 
1389
- async function writePlaintextCredential(credentialPath, token) {
1816
+ async function writePlaintextCredential(credentialPath, token, metadata = {}) {
1390
1817
  await fs.mkdir(path.dirname(credentialPath), { recursive: true, mode: 0o700 });
1391
1818
  await bestEffortChmod(path.dirname(credentialPath), 0o700);
1392
1819
  const payload = {
1393
1820
  version: 1,
1394
1821
  tokenEnvVar: TOKEN_ENV_VAR,
1395
- storage: 'plaintext-user-config',
1822
+ storage: 'user-scoped-credential-file',
1823
+ createdAt: new Date().toISOString(),
1824
+ metadata,
1396
1825
  token
1397
1826
  };
1398
1827
  await fs.writeFile(credentialPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
@@ -1494,6 +1923,46 @@ function parsePositiveInteger(value, name) {
1494
1923
  return parsed;
1495
1924
  }
1496
1925
 
1926
+ async function sleep(ms) {
1927
+ await new Promise((resolve) => setTimeout(resolve, ms));
1928
+ }
1929
+
1930
+
1931
+ function npmExecutable() {
1932
+ return os.platform() === 'win32' ? 'npm.cmd' : 'npm';
1933
+ }
1934
+
1935
+
1936
+ async function runProcess(command, args, io, { stream = true } = {}) {
1937
+ const spawnFn = io.spawn ?? spawn;
1938
+ return await new Promise((resolve, reject) => {
1939
+ const child = spawnFn(command, args, {
1940
+ stdio: ['ignore', 'pipe', 'pipe']
1941
+ });
1942
+ let stdout = '';
1943
+ let stderr = '';
1944
+ child.stdout?.on('data', (chunk) => {
1945
+ const text = String(chunk);
1946
+ stdout += text;
1947
+ if (stream) {
1948
+ io.stdout.write(text);
1949
+ }
1950
+ });
1951
+ child.stderr?.on('data', (chunk) => {
1952
+ const text = String(chunk);
1953
+ stderr += text;
1954
+ if (stream) {
1955
+ io.stderr.write(text);
1956
+ }
1957
+ });
1958
+ child.on('error', reject);
1959
+ child.on('close', (code) => {
1960
+ resolve({ code: code ?? 0, stdout, stderr });
1961
+ });
1962
+ });
1963
+ }
1964
+
1965
+
1497
1966
  async function readAll(stream) {
1498
1967
  let content = '';
1499
1968
  for await (const chunk of stream) {