@xmemo/client 0.4.126 → 0.4.127

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 +52 -3
  2. package/package.json +1 -1
  3. package/src/cli.js +403 -21
package/README.md CHANGED
@@ -25,8 +25,10 @@ xmemo smoke --client codex
25
25
  xmemo doctor
26
26
  xmemo discovery show
27
27
  xmemo setup
28
+ xmemo login
28
29
  xmemo status
29
30
  xmemo token status
31
+ xmemo token add --from-stdin
30
32
  xmemo env example --shell bash
31
33
  xmemo mcp list
32
34
  xmemo mcp config --client generic
@@ -43,15 +45,40 @@ xmemo privacy
43
45
  token values into project files.
44
46
  - The CLI generates one stable non-secret `XMEMO_AGENT_INSTANCE_ID` per local
45
47
  client profile and stores it in user-scoped config outside git.
46
- - `xmemo token set` refuses plaintext credential storage unless
48
+ - `xmemo login` and `xmemo token add` store tokens only in the user-scoped
49
+ XMemo CLI credential file outside git; token values are never printed.
50
+ - Legacy `xmemo token set` refuses plaintext credential storage unless
47
51
  `--allow-plaintext` is explicitly provided.
48
52
  - The npm package uses a `files` whitelist so only `bin`, `src`, `README.md`,
49
53
  and `LICENSE` are published.
50
54
 
51
55
  ## Token flow
52
56
 
57
+ Recommended personal-user flow:
58
+
59
+ ```bash
60
+ xmemo login
61
+ xmemo token status --verify
62
+ ```
63
+
64
+ `xmemo login` uses the hosted device-login flow when the service advertises it:
65
+ the CLI shows a browser URL and one-time code, the user authorizes in XMemo, and
66
+ the CLI stores the issued MCP token in the user-scoped credential file.
67
+
68
+ Users who already have a token can configure it directly without shell profiles:
69
+
70
+ ```bash
71
+ printf '%s\n' 'your-token' | xmemo token add --from-stdin
72
+ xmemo token status --verify
73
+ ```
74
+
75
+ This is the preferred fallback while a hosted service is rolling out device
76
+ login. It still avoids project files, MCP config files, logs, and chat
77
+ transcripts.
78
+
53
79
  Tokens should be created by the XMemo website or enterprise console, then
54
- stored in a user environment variable or enterprise secret manager:
80
+ stored with `xmemo login`, `xmemo token add`, a user environment variable, or an
81
+ enterprise secret manager:
55
82
 
56
83
  ```bash
57
84
  export XMEMO_KEY="your-token"
@@ -129,13 +156,35 @@ For clients without a verified user-scoped write path, generate a read-only
129
156
  template and apply it manually after review:
130
157
 
131
158
  ```bash
132
- xmemo mcp config --client copilot-cli --base-url "https://your-private-service.example"
159
+ xmemo mcp config --client copilot-cli
133
160
  xmemo mcp config --client generic --base-url "https://your-private-service.example" --json
134
161
  ```
135
162
 
136
163
  Only Codex and Cursor currently have write-capable helpers. Other client writes
137
164
  should only be added after their official user-scoped config format is verified.
138
165
 
166
+ ### Copilot CLI
167
+
168
+ Copilot CLI has `/mcp` management, but it does not currently document a stable
169
+ cross-platform user config file path/format for third-party tools to edit
170
+ directly. The recommended personal-user path is therefore local proxy mode:
171
+
172
+ ```bash
173
+ xmemo login
174
+ xmemo mcp config --client copilot-cli
175
+ xmemo mcp proxy
176
+ ```
177
+
178
+ The generated Copilot CLI template points at `http://127.0.0.1:8765/mcp` and
179
+ does not include token or identity headers. `xmemo mcp proxy` reads the token
180
+ saved by `xmemo login` or `xmemo token add --from-stdin`, adds the XMemo bearer
181
+ token and local agent identity, then forwards requests to `https://xmemo.dev/mcp`.
182
+ If you specifically want the older environment-variable template, run:
183
+
184
+ ```bash
185
+ xmemo mcp config --client copilot-cli --remote-env
186
+ ```
187
+
139
188
  ### Codex
140
189
 
141
190
  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.127",
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,4 +1,5 @@
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';
4
5
  import { randomUUID } from 'node:crypto';
@@ -8,7 +9,7 @@ const PACKAGE_NAME = '@xmemo/client';
8
9
  const FALLBACK_PACKAGE_NAME = '@yonro/xmemo-client';
9
10
  const COMMAND_NAME = 'xmemo';
10
11
  const LEGACY_COMMAND_NAME = 'memory-os';
11
- const CLI_VERSION = '0.4.126';
12
+ const CLI_VERSION = '0.4.127';
12
13
  const DEFAULT_SERVICE_URL = 'https://xmemo.dev';
13
14
  const TOKEN_ENV_VAR = 'XMEMO_KEY';
14
15
  const LEGACY_TOKEN_ENV_VAR = 'MEMORY_OS_MCP_TOKEN';
@@ -20,6 +21,10 @@ const MCP_SERVER_NAME = 'memory_os';
20
21
  const CODEX_PROFILE_TARGET = 'AGENTS.md';
21
22
  const CODEX_PROFILE_MARKER_START = '<!-- memory-os:codex-profile:start -->';
22
23
  const CODEX_PROFILE_MARKER_END = '<!-- memory-os:codex-profile:end -->';
24
+ const DEVICE_LOGIN_START_PATH = '/api/v1/auth/device/start';
25
+ const DEVICE_LOGIN_TOKEN_PATH = '/api/v1/auth/device/token';
26
+ const DEFAULT_PROXY_HOST = '127.0.0.1';
27
+ const DEFAULT_PROXY_PORT = 8765;
23
28
 
24
29
  const MCP_CLIENTS = new Map([
25
30
  ['codex', {
@@ -75,6 +80,10 @@ export async function run(args, io = defaultIo()) {
75
80
  return await setupCommand(args.slice(1), io);
76
81
  }
77
82
 
83
+ if (command === 'login') {
84
+ return await loginCommand(args.slice(1), io);
85
+ }
86
+
78
87
  if (command === 'token') {
79
88
  return await tokenCommand(args.slice(1), io);
80
89
  }
@@ -131,11 +140,14 @@ function writeHelp(io) {
131
140
  writeLine(io.stdout, ` ${COMMAND_NAME} doctor [--base-url <https://api.example.com>] [--json]`);
132
141
  writeLine(io.stdout, ` ${COMMAND_NAME} discovery show [--base-url <https://api.example.com>] [--json]`);
133
142
  writeLine(io.stdout, ` ${COMMAND_NAME} setup [codex|cursor] [--url <https://api.example.com>] [--write|--yes] [--json]`);
143
+ writeLine(io.stdout, ` ${COMMAND_NAME} login [--from-stdin] [--base-url <url>] [--json]`);
134
144
  writeLine(io.stdout, ` ${COMMAND_NAME} status [--url <https://api.example.com>] [--json]`);
135
- writeLine(io.stdout, ` ${COMMAND_NAME} token status`);
145
+ writeLine(io.stdout, ` ${COMMAND_NAME} token status [--verify]`);
146
+ writeLine(io.stdout, ` ${COMMAND_NAME} token add --from-stdin`);
136
147
  writeLine(io.stdout, ` ${COMMAND_NAME} token set --from-stdin [--allow-plaintext]`);
137
148
  writeLine(io.stdout, ` ${COMMAND_NAME} mcp list`);
138
- writeLine(io.stdout, ` ${COMMAND_NAME} mcp config --client <codex|cursor|generic> [--base-url <url>] [--json]`);
149
+ writeLine(io.stdout, ` ${COMMAND_NAME} mcp config --client <codex|cursor|copilot-cli|generic> [--base-url <url>] [--json]`);
150
+ writeLine(io.stdout, ` ${COMMAND_NAME} mcp proxy [--port ${DEFAULT_PROXY_PORT}]`);
139
151
  writeLine(io.stdout, ` ${COMMAND_NAME} mcp profile codex [--json]`);
140
152
  writeLine(io.stdout, ` ${COMMAND_NAME} profile install codex [--target AGENTS.md] [--dry-run|--json]`);
141
153
  writeLine(io.stdout, ` ${COMMAND_NAME} profile uninstall codex [--target AGENTS.md] [--json]`);
@@ -147,6 +159,7 @@ function writeHelp(io) {
147
159
  writeLine(io.stdout, `Default service URL: ${DEFAULT_SERVICE_URL}; use --url or XMEMO_URL for private deployments.`);
148
160
  writeLine(io.stdout, '');
149
161
  writeLine(io.stdout, 'Privacy defaults: no telemetry, no token in project files, and no token is sent by `status`, `doctor`, or `discovery`.');
162
+ writeLine(io.stdout, '`login` and `token add` store credentials only in the user-scoped XMemo CLI config directory.');
150
163
  }
151
164
 
152
165
  async function doctorCommand(args, io) {
@@ -385,46 +398,119 @@ async function profileCommand(args, io) {
385
398
  return 0;
386
399
  }
387
400
 
401
+ async function loginCommand(args, io) {
402
+ const outputJson = hasFlag(args, '--json');
403
+ const fromStdin = hasFlag(args, '--from-stdin') || hasFlag(args, '--token-stdin');
404
+ const baseUrl = normalizeBaseUrl(baseUrlOption(args, io.env));
405
+ const timeoutMs = parsePositiveInteger(optionValue(args, '--timeout-ms') ?? '30000', '--timeout-ms');
406
+ const pollOnce = hasFlag(args, '--poll-once');
407
+
408
+ if (fromStdin) {
409
+ const result = await storeTokenFromStdin(io, { source: 'stdin' });
410
+ if (outputJson) {
411
+ writeLine(io.stdout, JSON.stringify(result, null, 2));
412
+ } else {
413
+ writeLine(io.stdout, `${PRODUCT_NAME} login complete.`);
414
+ writeLine(io.stdout, `Stored token in user-scoped credential file: ${result.credentialPath}`);
415
+ writeLine(io.stdout, 'Token value was not printed. Project files were not modified.');
416
+ }
417
+ return 0;
418
+ }
419
+
420
+ const start = await startDeviceLogin(baseUrl, timeoutMs, io);
421
+ if (!outputJson) {
422
+ writeLine(io.stdout, `${PRODUCT_NAME} device login`);
423
+ writeLine(io.stdout, `Open: ${start.verificationUriComplete ?? start.verificationUri}`);
424
+ if (start.userCode) {
425
+ writeLine(io.stdout, `Code: ${start.userCode}`);
426
+ }
427
+ writeLine(io.stdout, 'Waiting for authorization...');
428
+ }
429
+
430
+ const token = await pollDeviceLogin(baseUrl, start, timeoutMs, io, { pollOnce });
431
+ const result = await storeTokenValue(token, { source: 'device-login' }, io.env);
432
+ const payload = {
433
+ ...result,
434
+ baseUrl,
435
+ verificationUri: start.verificationUri,
436
+ deviceLogin: true
437
+ };
438
+
439
+ if (outputJson) {
440
+ writeLine(io.stdout, JSON.stringify(payload, null, 2));
441
+ } else {
442
+ writeLine(io.stdout, 'Login complete. Token stored securely in the user-scoped XMemo CLI config directory.');
443
+ writeLine(io.stdout, `Credential path: ${result.credentialPath}`);
444
+ writeLine(io.stdout, `Verify with: ${COMMAND_NAME} token status --verify`);
445
+ }
446
+ return 0;
447
+ }
448
+
388
449
  async function tokenCommand(args, io) {
389
450
  const subcommand = args[0] ?? 'help';
390
451
 
391
452
  if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
392
453
  writeLine(io.stdout, 'Token commands:');
393
- writeLine(io.stdout, ` ${COMMAND_NAME} token status`);
454
+ writeLine(io.stdout, ` ${COMMAND_NAME} token status [--verify]`);
455
+ writeLine(io.stdout, ` ${COMMAND_NAME} token add --from-stdin`);
394
456
  writeLine(io.stdout, ` ${COMMAND_NAME} token set --from-stdin [--allow-plaintext]`);
395
457
  writeLine(io.stdout, '');
396
- writeLine(io.stdout, `Preferred enterprise path: set ${TOKEN_ENV_VAR} in your user or secret manager environment.`);
458
+ writeLine(io.stdout, `${COMMAND_NAME} login is the recommended personal-user path.`);
459
+ writeLine(io.stdout, `${COMMAND_NAME} token add --from-stdin stores a token in the user-scoped XMemo CLI config directory.`);
397
460
  return 0;
398
461
  }
399
462
 
400
463
  if (subcommand === 'status') {
401
- const credentialPath = credentialsPath(io.env);
464
+ const credential = await readStoredCredential(io.env);
402
465
  const hasEnvironmentToken = Boolean(io.env[TOKEN_ENV_VAR] ?? io.env[LEGACY_TOKEN_ENV_VAR]);
403
- const hasPlaintextCredential = await fileExists(credentialPath);
466
+ const hasUserCredential = Boolean(credential.token);
404
467
  writeLine(io.stdout, `Environment token: ${hasEnvironmentToken ? 'present' : 'missing'} (${TOKEN_ENV_VAR})`);
405
- writeLine(io.stdout, `User credential file: ${hasPlaintextCredential ? 'present' : 'missing'} (${credentialPath})`);
468
+ writeLine(io.stdout, `User credential file: ${hasUserCredential ? 'present' : 'missing'} (${credential.path})`);
406
469
  writeLine(io.stdout, 'Token values are never printed.');
407
- return hasEnvironmentToken || hasPlaintextCredential ? 0 : 1;
470
+ if (hasFlag(args, '--verify')) {
471
+ const token = await resolveCredentialToken(io.env);
472
+ if (!token) {
473
+ writeLine(io.stderr, `No token found. Run \`${COMMAND_NAME} login\` or \`${COMMAND_NAME} token add --from-stdin\`.`);
474
+ return 1;
475
+ }
476
+ const baseUrl = normalizeBaseUrl(baseUrlOption(args, io.env));
477
+ const timeoutMs = parsePositiveInteger(optionValue(args, '--timeout-ms') ?? '10000', '--timeout-ms');
478
+ const verification = await verifyTokenWithMcp(baseUrl, token, timeoutMs, io);
479
+ writeLine(io.stdout, `Remote token verification: ${verification.ok ? 'ok' : 'failed'} (${verification.detail})`);
480
+ return verification.ok ? 0 : 1;
481
+ }
482
+ return hasEnvironmentToken || hasUserCredential ? 0 : 1;
408
483
  }
409
484
 
410
- if (subcommand === 'set') {
485
+ if (subcommand === 'add') {
411
486
  if (!hasFlag(args, '--from-stdin')) {
412
487
  throw new UsageError('Refusing command-line token input. Pipe the token through stdin with --from-stdin.');
413
488
  }
489
+ const result = await storeTokenFromStdin(io, { source: 'token-add' });
490
+ if (hasFlag(args, '--json')) {
491
+ writeLine(io.stdout, JSON.stringify(result, null, 2));
492
+ } else {
493
+ writeLine(io.stdout, `Stored token in user-scoped credential file: ${result.credentialPath}`);
494
+ writeLine(io.stdout, 'Token value was not printed. Project files were not modified.');
495
+ }
496
+ return 0;
497
+ }
414
498
 
499
+ if (subcommand === 'set') {
500
+ if (!hasFlag(args, '--from-stdin')) {
501
+ throw new UsageError('Refusing command-line token input. Pipe the token through stdin with --from-stdin.');
502
+ }
415
503
  const token = (await readAll(io.stdin)).trim();
416
504
  validateToken(token);
417
-
418
505
  if (!hasFlag(args, '--allow-plaintext')) {
419
506
  writeLine(io.stderr, 'Token was read from stdin but was not stored.');
420
507
  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.`);
508
+ writeLine(io.stderr, `Preferred personal-user path: ${COMMAND_NAME} login or ${COMMAND_NAME} token add --from-stdin.`);
422
509
  return 2;
423
510
  }
424
511
 
425
- const credentialPath = credentialsPath(io.env);
426
- await writePlaintextCredential(credentialPath, token);
427
- writeLine(io.stdout, `Stored token in user-scoped credential file: ${credentialPath}`);
512
+ const result = await storeTokenValue(token, { source: 'token-set' }, io.env);
513
+ writeLine(io.stdout, `Stored token in user-scoped credential file: ${result.credentialPath}`);
428
514
  writeLine(io.stdout, 'Token value was not printed. Do not commit this file.');
429
515
  return 0;
430
516
  }
@@ -438,7 +524,8 @@ async function mcpCommand(args, io) {
438
524
  if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
439
525
  writeLine(io.stdout, 'MCP commands:');
440
526
  writeLine(io.stdout, ` ${COMMAND_NAME} mcp list`);
441
- writeLine(io.stdout, ` ${COMMAND_NAME} mcp config --client <codex|cursor|generic> [--base-url <url>] [--json]`);
527
+ writeLine(io.stdout, ` ${COMMAND_NAME} mcp config --client <codex|cursor|copilot-cli|generic> [--base-url <url>] [--json]`);
528
+ writeLine(io.stdout, ` ${COMMAND_NAME} mcp proxy [--port ${DEFAULT_PROXY_PORT}] [--base-url <url>]`);
442
529
  writeLine(io.stdout, ` ${COMMAND_NAME} mcp profile codex [--json]`);
443
530
  writeLine(io.stdout, ` ${COMMAND_NAME} mcp add <codex|cursor> [--url <https://api.example.com>]`);
444
531
  writeLine(io.stdout, ` ${COMMAND_NAME} mcp add <codex|cursor> [--url <https://api.example.com>] --write [--config <path>]`);
@@ -463,7 +550,12 @@ async function mcpCommand(args, io) {
463
550
  const clientId = optionValue(args, '--client') ?? args[1] ?? 'generic';
464
551
  const baseUrl = normalizeBaseUrl(baseUrlOption(args, io.env));
465
552
  const mcpUrl = endpointUrl(baseUrl, '/mcp');
466
- const template = mcpConfigTemplate(clientId, mcpUrl);
553
+ const useLocalProxy = clientId === 'copilot-cli' && !hasFlag(args, '--remote-env');
554
+ const proxyPort = parsePositiveInteger(optionValue(args, '--port') ?? String(DEFAULT_PROXY_PORT), '--port');
555
+ const proxyUrl = `http://${DEFAULT_PROXY_HOST}:${proxyPort}/mcp`;
556
+ const template = useLocalProxy
557
+ ? mcpLocalProxyTemplate(clientId, proxyUrl)
558
+ : mcpConfigTemplate(clientId, mcpUrl);
467
559
 
468
560
  if (hasFlag(args, '--json')) {
469
561
  writeLine(io.stdout, JSON.stringify(template, null, 2));
@@ -471,7 +563,12 @@ async function mcpCommand(args, io) {
471
563
  }
472
564
 
473
565
  writeLine(io.stdout, `${PRODUCT_NAME} MCP config template for ${clientId}`);
474
- writeLine(io.stdout, `Requires env: ${TOKEN_ENV_VAR}`);
566
+ if (useLocalProxy) {
567
+ writeLine(io.stdout, `Requires credential: ${COMMAND_NAME} login or ${COMMAND_NAME} token add --from-stdin`);
568
+ writeLine(io.stdout, `Run local proxy: ${template.requiresLocalCommand}`);
569
+ } else {
570
+ writeLine(io.stdout, `Requires env: ${TOKEN_ENV_VAR}`);
571
+ }
475
572
  if (typeof template.snippet === 'string') {
476
573
  writeLine(io.stdout, template.snippet.trimEnd());
477
574
  } else {
@@ -481,6 +578,10 @@ async function mcpCommand(args, io) {
481
578
  return 0;
482
579
  }
483
580
 
581
+ if (subcommand === 'proxy') {
582
+ return await mcpProxyCommand(args.slice(1), io);
583
+ }
584
+
484
585
  if (subcommand === 'profile') {
485
586
  const clientId = args[1] ?? 'codex';
486
587
  if (clientId !== 'codex') {
@@ -544,6 +645,82 @@ async function mcpCommand(args, io) {
544
645
  return 0;
545
646
  }
546
647
 
648
+ async function mcpProxyCommand(args, io) {
649
+ const baseUrl = normalizeBaseUrl(baseUrlOption(args, io.env));
650
+ const mcpUrl = endpointUrl(baseUrl, '/mcp');
651
+ const host = optionValue(args, '--host') ?? DEFAULT_PROXY_HOST;
652
+ const port = parsePositiveInteger(optionValue(args, '--port') ?? String(DEFAULT_PROXY_PORT), '--port');
653
+ const token = await resolveCredentialToken(io.env);
654
+ if (!token) {
655
+ throw new UsageError(`No token found. Run \`${COMMAND_NAME} login\` or \`${COMMAND_NAME} token add --from-stdin\` first.`);
656
+ }
657
+ validateToken(token);
658
+ const identity = await agentIdentity('copilot-cli', io.env);
659
+
660
+ const server = http.createServer(async (request, response) => {
661
+ try {
662
+ await handleMcpProxyRequest({ request, response, mcpUrl, token, identity, io });
663
+ } catch (error) {
664
+ response.statusCode = 502;
665
+ response.setHeader('content-type', 'application/json');
666
+ response.end(JSON.stringify({ error: 'mcp_proxy_error', message: error.message }));
667
+ }
668
+ });
669
+
670
+ await new Promise((resolve, reject) => {
671
+ server.once('error', reject);
672
+ server.listen(port, host, () => {
673
+ server.off('error', reject);
674
+ resolve();
675
+ });
676
+ });
677
+
678
+ writeLine(io.stdout, `${PRODUCT_NAME} MCP proxy listening on http://${host}:${port}/mcp`);
679
+ writeLine(io.stdout, `Forwarding to ${mcpUrl}`);
680
+ writeLine(io.stdout, `Credential source: ${TOKEN_ENV_VAR} or ${credentialsPath(io.env)}`);
681
+ return 0;
682
+ }
683
+
684
+ async function handleMcpProxyRequest({ request, response, mcpUrl, token, identity, io }) {
685
+ const requestUrl = new URL(request.url ?? '/', `http://${request.headers.host ?? `${DEFAULT_PROXY_HOST}:${DEFAULT_PROXY_PORT}`}`);
686
+ if (request.method !== 'POST' || requestUrl.pathname !== '/mcp') {
687
+ response.statusCode = 404;
688
+ response.setHeader('content-type', 'application/json');
689
+ response.end(JSON.stringify({ error: 'not_found' }));
690
+ return;
691
+ }
692
+
693
+ const body = await readAll(request);
694
+ const upstreamHeaders = {
695
+ accept: String(request.headers.accept || 'application/json, text/event-stream'),
696
+ 'content-type': String(request.headers['content-type'] || 'application/json'),
697
+ authorization: `Bearer ${token}`,
698
+ [AGENT_ID_HEADER]: identity.agentId,
699
+ [AGENT_INSTANCE_HEADER]: identity.agentInstanceId,
700
+ 'user-agent': `XMemo-CLI-Proxy/${CLI_VERSION} (+https://github.com/yonro/memory-os-cli)`
701
+ };
702
+ const sessionId = request.headers['mcp-session-id'];
703
+ if (sessionId) {
704
+ upstreamHeaders['mcp-session-id'] = Array.isArray(sessionId) ? sessionId[0] : sessionId;
705
+ }
706
+
707
+ const upstream = await io.fetch(mcpUrl, {
708
+ method: 'POST',
709
+ headers: upstreamHeaders,
710
+ body
711
+ });
712
+
713
+ response.statusCode = upstream.status;
714
+ for (const header of ['content-type', 'mcp-session-id']) {
715
+ const value = upstream.headers.get(header);
716
+ if (value) {
717
+ response.setHeader(header, value);
718
+ }
719
+ }
720
+ const buffer = Buffer.from(await upstream.arrayBuffer());
721
+ response.end(buffer);
722
+ }
723
+
547
724
  async function smokeCommand(args, io) {
548
725
  const clientId = optionValue(args, '--client');
549
726
  const outputJson = hasFlag(args, '--json');
@@ -636,10 +813,145 @@ function writePrivacy(io) {
636
813
  writeLine(io.stdout, '- `status` does not send tokens.');
637
814
  writeLine(io.stdout, `- MCP configs reference ${TOKEN_ENV_VAR}; token values are not embedded.`);
638
815
  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.');
816
+ writeLine(io.stdout, '- `login` and `token add` store credentials in the user-scoped XMemo CLI config directory.');
817
+ writeLine(io.stdout, '- Legacy `token set` plaintext storage requires explicit --allow-plaintext.');
640
818
  writeLine(io.stdout, '- npm publishing is restricted by package.json files whitelist.');
641
819
  }
642
820
 
821
+ async function startDeviceLogin(baseUrl, timeoutMs, io) {
822
+ const payload = await postJson(endpointUrl(baseUrl, DEVICE_LOGIN_START_PATH), {
823
+ client_id: PACKAGE_NAME,
824
+ cli_version: CLI_VERSION,
825
+ token_type: 'mcp_token',
826
+ scopes: ['memory:read', 'memory:write']
827
+ }, timeoutMs, io);
828
+
829
+ const deviceCode = stringValue(payload, ['device_code']);
830
+ const verificationUri = stringValue(payload, ['verification_uri']);
831
+ if (!deviceCode || !verificationUri) {
832
+ throw new UsageError(`Device login did not return device_code and verification_uri from ${baseUrl}.`);
833
+ }
834
+
835
+ return {
836
+ deviceCode,
837
+ userCode: stringValue(payload, ['user_code']),
838
+ verificationUri,
839
+ verificationUriComplete: stringValue(payload, ['verification_uri_complete']),
840
+ expiresIn: Number.isFinite(Number(payload.expires_in)) ? Number(payload.expires_in) : 600,
841
+ interval: Number.isFinite(Number(payload.interval)) ? Math.max(1, Number(payload.interval)) : 5
842
+ };
843
+ }
844
+
845
+ async function pollDeviceLogin(baseUrl, start, timeoutMs, io, options = {}) {
846
+ const deadline = Date.now() + Math.min(start.expiresIn * 1000, timeoutMs);
847
+ while (Date.now() <= deadline) {
848
+ const payload = await postJson(endpointUrl(baseUrl, DEVICE_LOGIN_TOKEN_PATH), {
849
+ device_code: start.deviceCode,
850
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
851
+ }, timeoutMs, io, { allowDevicePending: true });
852
+
853
+ const token = stringValue(payload, ['access_token']) ?? stringValue(payload, ['token']);
854
+ if (token) {
855
+ validateToken(token);
856
+ return token;
857
+ }
858
+
859
+ const error = stringValue(payload, ['error']);
860
+ if (error && error !== 'authorization_pending' && error !== 'slow_down') {
861
+ throw new UsageError(`Device login failed: ${error}`);
862
+ }
863
+ if (options.pollOnce) {
864
+ throw new UsageError('Device login is still pending.');
865
+ }
866
+ await sleep((error === 'slow_down' ? start.interval + 5 : start.interval) * 1000);
867
+ }
868
+
869
+ throw new UsageError('Device login expired before authorization completed.');
870
+ }
871
+
872
+ async function storeTokenFromStdin(io, metadata = {}) {
873
+ const token = (await readAll(io.stdin)).trim();
874
+ validateToken(token);
875
+ return await storeTokenValue(token, metadata, io.env);
876
+ }
877
+
878
+ async function storeTokenValue(token, metadata, env) {
879
+ validateToken(token);
880
+ const credentialPath = credentialsPath(env);
881
+ await writePlaintextCredential(credentialPath, token, metadata);
882
+ return {
883
+ ok: true,
884
+ credentialPath,
885
+ tokenPresent: true,
886
+ tokenPrinted: false,
887
+ projectFilesModified: false,
888
+ storage: 'user-scoped-credential-file'
889
+ };
890
+ }
891
+
892
+ async function readStoredCredential(env) {
893
+ const credentialPath = credentialsPath(env);
894
+ const content = await readTextIfExists(credentialPath);
895
+ if (!content.trim()) {
896
+ return { path: credentialPath, token: null };
897
+ }
898
+
899
+ const parsed = parseJsonConfig(content, credentialPath);
900
+ return {
901
+ path: credentialPath,
902
+ token: stringValue(parsed, ['token']),
903
+ storage: stringValue(parsed, ['storage'])
904
+ };
905
+ }
906
+
907
+ async function resolveCredentialToken(env) {
908
+ const environmentToken = env[TOKEN_ENV_VAR] ?? env[LEGACY_TOKEN_ENV_VAR];
909
+ if (environmentToken) {
910
+ return environmentToken;
911
+ }
912
+ const credential = await readStoredCredential(env);
913
+ return credential.token;
914
+ }
915
+
916
+ async function verifyTokenWithMcp(baseUrl, token, timeoutMs, io) {
917
+ const url = endpointUrl(baseUrl, '/mcp');
918
+ const controller = new AbortController();
919
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
920
+ try {
921
+ const response = await io.fetch(url, {
922
+ method: 'POST',
923
+ headers: {
924
+ accept: 'application/json, text/event-stream',
925
+ 'content-type': 'application/json',
926
+ authorization: `Bearer ${token}`,
927
+ 'user-agent': `XMemo-CLI/${CLI_VERSION} (+https://github.com/yonro/memory-os-cli)`
928
+ },
929
+ body: JSON.stringify({
930
+ jsonrpc: '2.0',
931
+ id: 1,
932
+ method: 'initialize',
933
+ params: {
934
+ protocolVersion: '2024-11-05',
935
+ capabilities: {},
936
+ clientInfo: { name: COMMAND_NAME, version: CLI_VERSION }
937
+ }
938
+ }),
939
+ signal: controller.signal
940
+ });
941
+ return {
942
+ ok: response.ok,
943
+ detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}`
944
+ };
945
+ } catch (error) {
946
+ return {
947
+ ok: false,
948
+ detail: error.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : error.message
949
+ };
950
+ } finally {
951
+ clearTimeout(timeout);
952
+ }
953
+ }
954
+
643
955
  async function probe(url, timeoutMs, io) {
644
956
  if (typeof io.fetch !== 'function') {
645
957
  return { url, ok: false, error: 'fetch unavailable in this Node runtime' };
@@ -693,6 +1005,44 @@ async function fetchJson(url, timeoutMs, io) {
693
1005
  }
694
1006
  }
695
1007
 
1008
+ async function postJson(url, payload, timeoutMs, io, options = {}) {
1009
+ if (typeof io.fetch !== 'function') {
1010
+ throw new UsageError('This Node runtime does not provide fetch; use Node.js 20 or newer.');
1011
+ }
1012
+
1013
+ const controller = new AbortController();
1014
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
1015
+
1016
+ try {
1017
+ const response = await io.fetch(url, {
1018
+ method: 'POST',
1019
+ headers: {
1020
+ accept: 'application/json',
1021
+ 'content-type': 'application/json'
1022
+ },
1023
+ body: JSON.stringify(payload),
1024
+ signal: controller.signal
1025
+ });
1026
+ const responsePayload = await response.json();
1027
+ if (!response.ok) {
1028
+ const error = stringValue(responsePayload, ['error']) ?? stringValue(responsePayload, ['detail']) ?? `HTTP ${response.status}`;
1029
+ if (options.allowDevicePending && (error === 'authorization_pending' || error === 'slow_down')) {
1030
+ return { error };
1031
+ }
1032
+ throw new UsageError(`Request failed with HTTP ${response.status}: ${url} (${error})`);
1033
+ }
1034
+ return responsePayload;
1035
+ } catch (error) {
1036
+ if (error instanceof UsageError) {
1037
+ throw error;
1038
+ }
1039
+ const reason = error.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : error.message;
1040
+ throw new UsageError(`Request failed: ${url} (${reason})`);
1041
+ } finally {
1042
+ clearTimeout(timeout);
1043
+ }
1044
+ }
1045
+
696
1046
  function ensureDiscoveryService(discovery, discoveryUrl) {
697
1047
  const service = stringValue(discovery, ['service']);
698
1048
  if (service && service !== 'memory-os') {
@@ -824,6 +1174,32 @@ function mcpConfigTemplate(clientId, mcpUrl) {
824
1174
  };
825
1175
  }
826
1176
 
1177
+ function mcpLocalProxyTemplate(clientId, proxyUrl) {
1178
+ const serverName = clientId === 'cursor' || clientId === 'gemini-cli' ? 'memory_os' : 'memory-os';
1179
+ return {
1180
+ client: clientId,
1181
+ serverName,
1182
+ snippetFormat: 'json',
1183
+ snippet: {
1184
+ mcpServers: {
1185
+ [serverName]: {
1186
+ type: 'http',
1187
+ url: proxyUrl
1188
+ }
1189
+ }
1190
+ },
1191
+ requiresCredential: [`${COMMAND_NAME} login`, `${COMMAND_NAME} token add --from-stdin`],
1192
+ requiresLocalCommand: `${COMMAND_NAME} mcp proxy --port ${new URL(proxyUrl).port || DEFAULT_PROXY_PORT}`,
1193
+ agentIdentity: {
1194
+ agentId: clientId,
1195
+ agentIdHeader: AGENT_ID_HEADER,
1196
+ agentInstanceEnvVar: AGENT_INSTANCE_ENV_VAR,
1197
+ agentInstanceHeader: AGENT_INSTANCE_HEADER
1198
+ },
1199
+ writesTokenValue: false
1200
+ };
1201
+ }
1202
+
827
1203
  function sameMajorMinor(left, right) {
828
1204
  const leftParts = left.split('.');
829
1205
  const rightParts = right.split('.');
@@ -1386,13 +1762,15 @@ function defaultCursorConfigPath(env) {
1386
1762
  return path.join(home, '.cursor', 'mcp.json');
1387
1763
  }
1388
1764
 
1389
- async function writePlaintextCredential(credentialPath, token) {
1765
+ async function writePlaintextCredential(credentialPath, token, metadata = {}) {
1390
1766
  await fs.mkdir(path.dirname(credentialPath), { recursive: true, mode: 0o700 });
1391
1767
  await bestEffortChmod(path.dirname(credentialPath), 0o700);
1392
1768
  const payload = {
1393
1769
  version: 1,
1394
1770
  tokenEnvVar: TOKEN_ENV_VAR,
1395
- storage: 'plaintext-user-config',
1771
+ storage: 'user-scoped-credential-file',
1772
+ createdAt: new Date().toISOString(),
1773
+ metadata,
1396
1774
  token
1397
1775
  };
1398
1776
  await fs.writeFile(credentialPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
@@ -1494,6 +1872,10 @@ function parsePositiveInteger(value, name) {
1494
1872
  return parsed;
1495
1873
  }
1496
1874
 
1875
+ async function sleep(ms) {
1876
+ await new Promise((resolve) => setTimeout(resolve, ms));
1877
+ }
1878
+
1497
1879
  async function readAll(stream) {
1498
1880
  let content = '';
1499
1881
  for await (const chunk of stream) {