@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.
- package/README.md +52 -3
- package/package.json +1 -1
- 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
|
|
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
|
|
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
|
|
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
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.
|
|
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,
|
|
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
|
|
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
|
|
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: ${
|
|
468
|
+
writeLine(io.stdout, `User credential file: ${hasUserCredential ? 'present' : 'missing'} (${credential.path})`);
|
|
406
469
|
writeLine(io.stdout, 'Token values are never printed.');
|
|
407
|
-
|
|
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 === '
|
|
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
|
|
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
|
|
426
|
-
|
|
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
|
|
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
|
-
|
|
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, '-
|
|
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: '
|
|
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) {
|