@echomem/mcp 1.4.19 → 1.4.21
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 +12 -5
- package/dist/codex-session-files.js +7 -1
- package/dist/crypto.js +4 -0
- package/dist/encryption.js +14 -2
- package/dist/hud/cli.js +0 -0
- package/dist/index.js +59 -125
- package/dist/keystore.js +9 -19
- package/dist/migrate.js +88 -33
- package/dist/setup-page/client-core.js +194 -27
- package/dist/setup-page/client-extraction.js +129 -39
- package/dist/setup-page/client-lifecycle.js +1 -5
- package/dist/setup-page/client-report-city.js +14 -5
- package/dist/setup-page/styles-extraction.js +200 -6
- package/dist/setup-page/styles-website-alignment.js +183 -0
- package/dist/setup-page.js +1 -0
- package/dist/setup-preview.js +140 -37
- package/dist/setup.js +441 -45
- package/dist/v1-contract.js +1 -11
- package/package.json +6 -2
- package/templates/codex-skills/echomem-forget/SKILL.md +16 -0
- package/templates/codex-skills/echomem-forget/agents/openai.yaml +6 -0
- package/templates/codex-skills/echomem-login/SKILL.md +27 -0
- package/templates/codex-skills/echomem-login/agents/openai.yaml +6 -0
- package/templates/codex-skills/echomem-save/SKILL.md +14 -0
- package/templates/codex-skills/echomem-save/agents/openai.yaml +6 -0
- package/templates/codex-skills/echomem-search/SKILL.md +14 -0
- package/templates/codex-skills/echomem-search/agents/openai.yaml +6 -0
- package/dist/context-analysis/claude-canonical-adapter.js +0 -315
- package/dist/hud/efficiency.js +0 -447
package/dist/setup.js
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
* `echomem-mcp init | setup | login | unlock | status | logout` — onboarding for the local bridge (spec §8).
|
|
3
3
|
*
|
|
4
4
|
* Design goals from the spec:
|
|
5
|
-
* - One command → one browser
|
|
5
|
+
* - One command → one local browser setup → one reload.
|
|
6
6
|
* - Both secrets (API token + encryption key) ride a single browser flow and land in the local
|
|
7
7
|
* keystore — never in the client's MCP config, never in the agent's chat context.
|
|
8
8
|
* - Re-unlock after the key's TTL is one step, not a re-setup.
|
|
9
9
|
*
|
|
10
|
-
* The browser flow
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* The browser flow stays on a localhost setup page. That local page collects email OTP consent and
|
|
11
|
+
* an encryption passphrase, while this process asks the hosted API to send/verify OTP and mint a
|
|
12
|
+
* device token. Secrets land in the local keystore — never in the client's MCP config, never in
|
|
13
|
+
* the agent's chat context.
|
|
14
14
|
*/
|
|
15
15
|
import http from "node:http";
|
|
16
16
|
import { randomUUID } from "node:crypto";
|
|
@@ -23,7 +23,7 @@ import readline from "node:readline";
|
|
|
23
23
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
24
24
|
import axios from "axios";
|
|
25
25
|
import { KeyStore } from "./keystore.js";
|
|
26
|
-
import { fetchEncryptionConfig, deriveAndVerifyKey, verifyKeyB64 } from "./encryption.js";
|
|
26
|
+
import { fetchEncryptionConfig, deriveAndVerifyKey, setupNewEncryptionKey, verifyKeyB64 } from "./encryption.js";
|
|
27
27
|
import { collect, runReport, buildStatsPayload } from "./report.js";
|
|
28
28
|
import { cmdMigrate, applyAccountImportStatus, applyFastAccountImportStatus, discoverMigratableFastDiscovery, discoverMigratableSessions, discoverPendingSessionsTargeted, estimateMigrationEta, fetchProcessedImportKeys, isImportStatusUnsupported, markAccountImportStatusFailed, markAccountImportStatusUnavailable, markFastAccountImportStatusUnavailable, startMigration, summarizeFastMigratableDiscovery, MIGRATE_CONCURRENCY, } from "./migrate.js";
|
|
29
29
|
import { syncCodexUsage } from "./codex-sync.js";
|
|
@@ -33,15 +33,111 @@ import { repoLabel, validateForensicReportForSetup } from "./forensics.js";
|
|
|
33
33
|
import { installHooks } from "./hud/hooks.js";
|
|
34
34
|
import { MCP_PACKAGE_LABEL, MCP_PACKAGE_NAME, MCP_PACKAGE_VERSION, MCP_UPDATE_ALL_COMMAND, MCP_UPDATE_COMMAND } from "./package-metadata.js";
|
|
35
35
|
import { checkLatestUpdateStatus, compareSemver, readCachedUpdateStatus } from "./update-check.js";
|
|
36
|
-
// The
|
|
37
|
-
//
|
|
38
|
-
// device unless the user explicitly starts migration. Override the hosted auth origin with ECHO_WEB_URL.
|
|
39
|
-
const WEB_URL = (process.env.ECHO_WEB_URL || "https://yeahecho.com").replace(/\/$/, "");
|
|
36
|
+
// The setup dashboard, account login, and encryption passphrase entry are all served by this
|
|
37
|
+
// localhost bridge. The hosted API only sends OTP email, verifies the code, and mints a device token.
|
|
40
38
|
const API_BASE_URL = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
|
|
41
39
|
const PRICING_URL = (process.env.ECHO_PRICING_URL || "https://echoknows.com/pricing").replace(/\/$/, "");
|
|
40
|
+
const CODEX_SKILL_NAMES = [
|
|
41
|
+
"echomem-search",
|
|
42
|
+
"echomem-save",
|
|
43
|
+
"echomem-forget",
|
|
44
|
+
"echomem-login",
|
|
45
|
+
];
|
|
42
46
|
function home(...p) {
|
|
43
47
|
return path.join(os.homedir(), ...p);
|
|
44
48
|
}
|
|
49
|
+
function codexHome() {
|
|
50
|
+
const configured = process.env.CODEX_HOME?.trim();
|
|
51
|
+
if (!configured)
|
|
52
|
+
return home(".codex");
|
|
53
|
+
if (configured === "~")
|
|
54
|
+
return os.homedir();
|
|
55
|
+
if (configured.startsWith(`~${path.sep}`))
|
|
56
|
+
return path.join(os.homedir(), configured.slice(2));
|
|
57
|
+
return path.resolve(configured);
|
|
58
|
+
}
|
|
59
|
+
function filesEqual(left, right) {
|
|
60
|
+
try {
|
|
61
|
+
return fs.readFileSync(left).equals(fs.readFileSync(right));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function packagedSkillMatches(source, destination) {
|
|
68
|
+
return filesEqual(path.join(source, "SKILL.md"), path.join(destination, "SKILL.md"))
|
|
69
|
+
&& filesEqual(path.join(source, "agents", "openai.yaml"), path.join(destination, "agents", "openai.yaml"));
|
|
70
|
+
}
|
|
71
|
+
function readSkillMetadata(skillFile) {
|
|
72
|
+
try {
|
|
73
|
+
const content = fs.readFileSync(skillFile, "utf8");
|
|
74
|
+
const frontmatter = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
75
|
+
if (!frontmatter)
|
|
76
|
+
return null;
|
|
77
|
+
const name = frontmatter[1].match(/^name:\s*(.+)$/m)?.[1]?.trim().replace(/^['"]|['"]$/g, "") ?? "";
|
|
78
|
+
const description = frontmatter[1].match(/^description:\s*(.+)$/m)?.[1]?.trim().replace(/^['"]|['"]$/g, "") ?? "";
|
|
79
|
+
return name ? { name, description } : null;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function detectCompetingMemorySkills(skillsRoot) {
|
|
86
|
+
let entries = [];
|
|
87
|
+
try {
|
|
88
|
+
entries = fs.readdirSync(skillsRoot, { withFileTypes: true });
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
const echoNames = new Set(CODEX_SKILL_NAMES);
|
|
94
|
+
return entries
|
|
95
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
|
|
96
|
+
.map((entry) => readSkillMetadata(path.join(skillsRoot, entry.name, "SKILL.md")))
|
|
97
|
+
.filter((metadata) => Boolean(metadata))
|
|
98
|
+
.filter(({ name, description }) => {
|
|
99
|
+
if (echoNames.has(name))
|
|
100
|
+
return false;
|
|
101
|
+
const text = `${name} ${description}`;
|
|
102
|
+
return /\b(memory|memories|remember|recall)\b/i.test(text)
|
|
103
|
+
&& /\b(search|save|forget|delete|login|connect|recall|remember)\b/i.test(text);
|
|
104
|
+
})
|
|
105
|
+
.map(({ name }) => name)
|
|
106
|
+
.sort();
|
|
107
|
+
}
|
|
108
|
+
/** Install the EchoMem-owned Codex skills bundled with this npm package. Other providers are never modified. */
|
|
109
|
+
export function installCodexSkills(targetCodexHome = codexHome(), templateRoot = fileURLToPath(new URL("../templates/codex-skills/", import.meta.url))) {
|
|
110
|
+
const skillsRoot = path.join(targetCodexHome, "skills");
|
|
111
|
+
const report = {
|
|
112
|
+
skillsRoot,
|
|
113
|
+
installed: [],
|
|
114
|
+
updated: [],
|
|
115
|
+
unchanged: [],
|
|
116
|
+
competingMemorySkills: [],
|
|
117
|
+
};
|
|
118
|
+
fs.mkdirSync(skillsRoot, { recursive: true });
|
|
119
|
+
for (const skillName of CODEX_SKILL_NAMES) {
|
|
120
|
+
const source = path.join(templateRoot, skillName);
|
|
121
|
+
const destination = path.join(skillsRoot, skillName);
|
|
122
|
+
const sourceSkillFile = path.join(source, "SKILL.md");
|
|
123
|
+
if (!fs.existsSync(sourceSkillFile) || !fs.statSync(sourceSkillFile).isFile()) {
|
|
124
|
+
throw new Error(`EchoMem package is missing the ${skillName} skill template.`);
|
|
125
|
+
}
|
|
126
|
+
if (!fs.existsSync(destination)) {
|
|
127
|
+
fs.cpSync(source, destination, { recursive: true, force: true });
|
|
128
|
+
report.installed.push(skillName);
|
|
129
|
+
}
|
|
130
|
+
else if (packagedSkillMatches(source, destination)) {
|
|
131
|
+
report.unchanged.push(skillName);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
fs.cpSync(source, destination, { recursive: true, force: true });
|
|
135
|
+
report.updated.push(skillName);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
report.competingMemorySkills = detectCompetingMemorySkills(skillsRoot);
|
|
139
|
+
return report;
|
|
140
|
+
}
|
|
45
141
|
/** Map a source entry to its compiled sibling without ever guessing outside this package. */
|
|
46
142
|
export function compiledDistPathForSource(entry) {
|
|
47
143
|
if (!entry.endsWith(".ts") || !path.isAbsolute(entry))
|
|
@@ -96,7 +192,7 @@ export function knownClients() {
|
|
|
96
192
|
{ id: "windsurf", label: "Windsurf", kind: "json", configPath: home(".codeium", "windsurf", "mcp_config.json") },
|
|
97
193
|
{ id: "claude-desktop", label: "Claude Desktop", kind: "json", configPath: path.join(appSupport, "Claude", "claude_desktop_config.json") },
|
|
98
194
|
{ id: "claude-code", label: "Claude Code", kind: "snippet", note: "run: claude mcp add-json echomem '<entry>' (or add to .mcp.json)" },
|
|
99
|
-
{ id: "codex", label: "Codex", kind: "command", detectDir:
|
|
195
|
+
{ id: "codex", label: "Codex", kind: "command", detectDir: codexHome(), configPath: path.join(codexHome(), "config.toml"), note: "add to ~/.codex/config.toml under [mcp_servers.echomem]" },
|
|
100
196
|
];
|
|
101
197
|
}
|
|
102
198
|
/** A client is "present" if its config dir already exists (JSON) — a cheap heuristic for detection. */
|
|
@@ -222,6 +318,7 @@ function echomemGuidanceBlock() {
|
|
|
222
318
|
AGENTS_MD_BEGIN,
|
|
223
319
|
"## Memory (EchoMem)",
|
|
224
320
|
"EchoMem is your long-term memory across all coding sessions and tools.",
|
|
321
|
+
"- Use EchoMem's `echomem-*` skills and MCP tools as the default memory provider. Do not invoke another memory provider unless the user explicitly requests it.",
|
|
225
322
|
"- Before re-deriving past decisions, project context, or anything the user likely settled before: call `search_memories` first.",
|
|
226
323
|
"- When meaningful work wraps up (a decision, a fix, a milestone) or the user asks to remember something: call `save_conversation`.",
|
|
227
324
|
"- If the user pastes a session carryover/checkpoint: it may reference `get_checkpoint_by_context` — use it to pull the checkpoint/decision trail when you need more than the snapshot.",
|
|
@@ -1092,9 +1189,9 @@ function publicRunningForensicProgress(value) {
|
|
|
1092
1189
|
};
|
|
1093
1190
|
}
|
|
1094
1191
|
/**
|
|
1095
|
-
* Start the persistent localhost bridge used by the
|
|
1096
|
-
* serves local Wrapped stats, and holds the /migrate
|
|
1097
|
-
* import session.
|
|
1192
|
+
* Start the persistent localhost bridge used by the setup page. It sends/verifies OTP through the
|
|
1193
|
+
* hosted API, accepts the local passphrase, serves local Wrapped stats, and holds the /migrate
|
|
1194
|
+
* response until cmdLogin has created a cloud import session.
|
|
1098
1195
|
*/
|
|
1099
1196
|
export function startCallbackServer(opts = {}) {
|
|
1100
1197
|
const timeoutMs = opts.timeoutMs ?? 15 * 60_000;
|
|
@@ -1112,6 +1209,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
1112
1209
|
let authUrl = "";
|
|
1113
1210
|
let switchAccountUrl = "";
|
|
1114
1211
|
let connected = false;
|
|
1212
|
+
let pendingLocalAuth = null;
|
|
1115
1213
|
let reportConsentGranted = opts.requireReportConsent !== true;
|
|
1116
1214
|
let progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
|
|
1117
1215
|
let migrateStarted = false;
|
|
@@ -1128,6 +1226,26 @@ export function startCallbackServer(opts = {}) {
|
|
|
1128
1226
|
const checkNonce = (nonce) => !expectedNonce || nonce === expectedNonce;
|
|
1129
1227
|
const text = (res, status, body = "") => res.writeHead(status, { "Content-Type": "text/plain" }).end(body);
|
|
1130
1228
|
const json = (res, status, body) => res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body));
|
|
1229
|
+
const revokeDeviceToken = async (token) => {
|
|
1230
|
+
try {
|
|
1231
|
+
await authedAxios(token).post("/api/extension/mcp/local-auth/revoke-device", {}, { timeout: 10_000 });
|
|
1232
|
+
return true;
|
|
1233
|
+
}
|
|
1234
|
+
catch (error) {
|
|
1235
|
+
console.error(`Could not revoke incomplete local login: ${error instanceof Error ? error.message : String(error)}`);
|
|
1236
|
+
return false;
|
|
1237
|
+
}
|
|
1238
|
+
};
|
|
1239
|
+
const revokePendingLocalAuth = async () => {
|
|
1240
|
+
const pending = pendingLocalAuth;
|
|
1241
|
+
pendingLocalAuth = null;
|
|
1242
|
+
if (!pending)
|
|
1243
|
+
return false;
|
|
1244
|
+
return revokeDeviceToken(pending.token);
|
|
1245
|
+
};
|
|
1246
|
+
const completeDeviceLogin = async (token) => {
|
|
1247
|
+
await authedAxios(token).post("/api/extension/mcp/local-auth/complete-device", {}, { timeout: 10_000 });
|
|
1248
|
+
};
|
|
1131
1249
|
const close = () => {
|
|
1132
1250
|
if (timer)
|
|
1133
1251
|
clearTimeout(timer);
|
|
@@ -1135,6 +1253,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
1135
1253
|
if (closed)
|
|
1136
1254
|
return;
|
|
1137
1255
|
closed = true;
|
|
1256
|
+
void revokePendingLocalAuth();
|
|
1138
1257
|
server.close();
|
|
1139
1258
|
for (const socket of sockets)
|
|
1140
1259
|
socket.destroy();
|
|
@@ -1146,7 +1265,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
1146
1265
|
const waitMs = onToken.settled() ? dashboardTimeoutMs : timeoutMs;
|
|
1147
1266
|
timer = setTimeout(() => {
|
|
1148
1267
|
if (!onToken.settled()) {
|
|
1149
|
-
onToken.reject(new Error(`
|
|
1268
|
+
onToken.reject(new Error(`local setup did not finish in ${approvalTimeoutLabel}`));
|
|
1150
1269
|
}
|
|
1151
1270
|
else if (!decision.settled()) {
|
|
1152
1271
|
decision.resolve("timeout");
|
|
@@ -1166,7 +1285,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
1166
1285
|
}
|
|
1167
1286
|
if (!token)
|
|
1168
1287
|
return void text(res, 400, "missing token");
|
|
1169
|
-
console.log(`[${new Date().toISOString()}]
|
|
1288
|
+
console.log(`[${new Date().toISOString()}] Device token callback received.`);
|
|
1170
1289
|
const firstToken = !onToken.settled();
|
|
1171
1290
|
connected = true;
|
|
1172
1291
|
const setupPath = `/setup?nonce=${encodeURIComponent(nonce || "")}&connected=1`;
|
|
@@ -1182,12 +1301,172 @@ export function startCallbackServer(opts = {}) {
|
|
|
1182
1301
|
}
|
|
1183
1302
|
armTimeout();
|
|
1184
1303
|
};
|
|
1304
|
+
const rejectIfLocalAuthBlocked = (res, nonce) => {
|
|
1305
|
+
if (!checkNonce(nonce)) {
|
|
1306
|
+
text(res, 403, "bad nonce");
|
|
1307
|
+
return true;
|
|
1308
|
+
}
|
|
1309
|
+
if (opts.requireReportConsent === true && !reportConsentGranted) {
|
|
1310
|
+
json(res, 403, {
|
|
1311
|
+
error: "LOCAL_HISTORY_CONSENT_REQUIRED",
|
|
1312
|
+
message: "Allow local history access in the setup page before connecting EchoMem.",
|
|
1313
|
+
});
|
|
1314
|
+
return true;
|
|
1315
|
+
}
|
|
1316
|
+
return false;
|
|
1317
|
+
};
|
|
1318
|
+
const resolveLocalToken = (token, key) => {
|
|
1319
|
+
const firstToken = !onToken.settled();
|
|
1320
|
+
connected = true;
|
|
1321
|
+
pendingLocalAuth = null;
|
|
1322
|
+
const callbackToken = { token, key };
|
|
1323
|
+
if (firstToken) {
|
|
1324
|
+
onToken.resolve(callbackToken);
|
|
1325
|
+
}
|
|
1326
|
+
else if (tokenRefreshHandler) {
|
|
1327
|
+
Promise.resolve(tokenRefreshHandler(callbackToken)).catch((e) => {
|
|
1328
|
+
console.error(`Could not refresh local login: ${e instanceof Error ? e.message : String(e)}`);
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
armTimeout();
|
|
1332
|
+
};
|
|
1333
|
+
const handleLocalSendOtp = async (res, body) => {
|
|
1334
|
+
if (rejectIfLocalAuthBlocked(res, asString(body.nonce)))
|
|
1335
|
+
return;
|
|
1336
|
+
const email = normalizeEmail(body.email);
|
|
1337
|
+
if (!validEmail(email))
|
|
1338
|
+
return void json(res, 400, { ok: false, error: "Invalid email address" });
|
|
1339
|
+
if (!acceptedLocalTerms(body)) {
|
|
1340
|
+
return void json(res, 400, {
|
|
1341
|
+
ok: false,
|
|
1342
|
+
error: "Confirm your age and accept Echo's terms before continuing.",
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
try {
|
|
1346
|
+
const response = await apiAxios().post("/api/extension/mcp/local-auth/send-otp", {
|
|
1347
|
+
email,
|
|
1348
|
+
acceptedTerms: true,
|
|
1349
|
+
ageConfirmed: true,
|
|
1350
|
+
}, { timeout: 10_000 });
|
|
1351
|
+
json(res, 200, { ok: true, email: asString(response.data?.email) || email });
|
|
1352
|
+
}
|
|
1353
|
+
catch (error) {
|
|
1354
|
+
const detail = publicAxiosError(error, "Failed to send verification code");
|
|
1355
|
+
json(res, detail.status, { ok: false, error: detail.message });
|
|
1356
|
+
}
|
|
1357
|
+
};
|
|
1358
|
+
const handleLocalVerifyOtp = async (res, body) => {
|
|
1359
|
+
if (rejectIfLocalAuthBlocked(res, asString(body.nonce)))
|
|
1360
|
+
return;
|
|
1361
|
+
const email = normalizeEmail(body.email);
|
|
1362
|
+
const otp = asString(body.otp)?.trim() || "";
|
|
1363
|
+
if (!validEmail(email))
|
|
1364
|
+
return void json(res, 400, { ok: false, error: "Invalid email address" });
|
|
1365
|
+
if (!/^\d{6}$/.test(otp)) {
|
|
1366
|
+
return void json(res, 400, { ok: false, error: "Enter the 6-digit verification code." });
|
|
1367
|
+
}
|
|
1368
|
+
if (!acceptedLocalTerms(body)) {
|
|
1369
|
+
return void json(res, 400, {
|
|
1370
|
+
ok: false,
|
|
1371
|
+
error: "Confirm your age and accept Echo's terms before continuing.",
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
try {
|
|
1375
|
+
const response = await apiAxios().post("/api/extension/mcp/local-auth/verify-otp", {
|
|
1376
|
+
email,
|
|
1377
|
+
otp,
|
|
1378
|
+
acceptedTerms: true,
|
|
1379
|
+
ageConfirmed: true,
|
|
1380
|
+
}, { timeout: 15_000 });
|
|
1381
|
+
const token = asString(response.data?.api_key);
|
|
1382
|
+
const userId = asString(response.data?.user_id);
|
|
1383
|
+
if (!token || !userId) {
|
|
1384
|
+
return void json(res, 502, { ok: false, error: "The EchoMem API did not return a device token." });
|
|
1385
|
+
}
|
|
1386
|
+
let config;
|
|
1387
|
+
try {
|
|
1388
|
+
config = await fetchEncryptionConfig(authedAxios(token));
|
|
1389
|
+
}
|
|
1390
|
+
catch (error) {
|
|
1391
|
+
await revokeDeviceToken(token);
|
|
1392
|
+
throw error;
|
|
1393
|
+
}
|
|
1394
|
+
await revokePendingLocalAuth();
|
|
1395
|
+
pendingLocalAuth = {
|
|
1396
|
+
token,
|
|
1397
|
+
userId,
|
|
1398
|
+
email: asString(response.data?.email) || email,
|
|
1399
|
+
mode: config.enabled ? "unlock" : "setup",
|
|
1400
|
+
config: config.enabled ? config : undefined,
|
|
1401
|
+
expiresAtMs: Date.now() + 10 * 60_000,
|
|
1402
|
+
};
|
|
1403
|
+
json(res, 200, {
|
|
1404
|
+
ok: true,
|
|
1405
|
+
stage: "passphrase",
|
|
1406
|
+
mode: pendingLocalAuth.mode,
|
|
1407
|
+
email: pendingLocalAuth.email,
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
catch (error) {
|
|
1411
|
+
const detail = publicAxiosError(error, "Failed to verify code");
|
|
1412
|
+
json(res, detail.status, { ok: false, error: detail.message });
|
|
1413
|
+
}
|
|
1414
|
+
};
|
|
1415
|
+
const handleLocalPassphrase = async (res, body) => {
|
|
1416
|
+
if (rejectIfLocalAuthBlocked(res, asString(body.nonce)))
|
|
1417
|
+
return;
|
|
1418
|
+
const passphrase = typeof body.passphrase === "string" ? body.passphrase : "";
|
|
1419
|
+
if (passphrase.length < 4) {
|
|
1420
|
+
return void json(res, 400, { ok: false, error: "Enter an encryption passphrase with at least 4 characters." });
|
|
1421
|
+
}
|
|
1422
|
+
if (!pendingLocalAuth || pendingLocalAuth.expiresAtMs <= Date.now()) {
|
|
1423
|
+
await revokePendingLocalAuth();
|
|
1424
|
+
return void json(res, 409, {
|
|
1425
|
+
ok: false,
|
|
1426
|
+
error: "This local login expired. Send a new verification code.",
|
|
1427
|
+
reset: true,
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
try {
|
|
1431
|
+
if (pendingLocalAuth.mode === "unlock") {
|
|
1432
|
+
const key = pendingLocalAuth.config
|
|
1433
|
+
? await deriveAndVerifyKey(passphrase, pendingLocalAuth.config)
|
|
1434
|
+
: null;
|
|
1435
|
+
if (!key)
|
|
1436
|
+
return void json(res, 400, { ok: false, error: "Incorrect encryption passphrase." });
|
|
1437
|
+
await completeDeviceLogin(pendingLocalAuth.token);
|
|
1438
|
+
resolveLocalToken(pendingLocalAuth.token, key);
|
|
1439
|
+
return void json(res, 200, { ok: true, connected: true });
|
|
1440
|
+
}
|
|
1441
|
+
const setup = await setupNewEncryptionKey(passphrase);
|
|
1442
|
+
await authedAxios(pendingLocalAuth.token).post("/api/extension/account/encryption", {
|
|
1443
|
+
salt: setup.saltBase64,
|
|
1444
|
+
verification: setup.verification,
|
|
1445
|
+
iterations: setup.iterations,
|
|
1446
|
+
}, {
|
|
1447
|
+
timeout: 10_000,
|
|
1448
|
+
headers: { "X-Encryption-Key": setup.keyBase64 },
|
|
1449
|
+
});
|
|
1450
|
+
pendingLocalAuth.mode = "unlock";
|
|
1451
|
+
pendingLocalAuth.config = {
|
|
1452
|
+
enabled: true,
|
|
1453
|
+
salt: setup.saltBase64,
|
|
1454
|
+
verification: setup.verification,
|
|
1455
|
+
iterations: setup.iterations,
|
|
1456
|
+
};
|
|
1457
|
+
await completeDeviceLogin(pendingLocalAuth.token);
|
|
1458
|
+
resolveLocalToken(pendingLocalAuth.token, setup.keyBase64);
|
|
1459
|
+
json(res, 200, { ok: true, connected: true });
|
|
1460
|
+
}
|
|
1461
|
+
catch (error) {
|
|
1462
|
+
const detail = publicAxiosError(error, "Failed to unlock encrypted memory");
|
|
1463
|
+
json(res, detail.status, { ok: false, error: detail.message });
|
|
1464
|
+
}
|
|
1465
|
+
};
|
|
1185
1466
|
server = http.createServer((req, res) => {
|
|
1186
1467
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
1187
1468
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
1188
1469
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
1189
|
-
// Let the HTTPS connect-device page reach this localhost server (Chrome Private Network Access).
|
|
1190
|
-
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
1191
1470
|
if (req.method === "OPTIONS")
|
|
1192
1471
|
return void res.writeHead(204).end();
|
|
1193
1472
|
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
@@ -1231,6 +1510,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
1231
1510
|
authUrl,
|
|
1232
1511
|
switchAccountUrl: switchAccountUrl || authUrl,
|
|
1233
1512
|
localOnly: true,
|
|
1513
|
+
localAuth: true,
|
|
1234
1514
|
workspacePath: process.cwd(),
|
|
1235
1515
|
consentRequired: opts.requireReportConsent === true,
|
|
1236
1516
|
consentGranted: reportConsentGranted,
|
|
@@ -1286,6 +1566,42 @@ export function startCallbackServer(opts = {}) {
|
|
|
1286
1566
|
handleCallback(res, asString(body.token), asString(body.key), asString(body.nonce));
|
|
1287
1567
|
return;
|
|
1288
1568
|
}
|
|
1569
|
+
if (route === "/local-auth/send-otp" && req.method === "POST") {
|
|
1570
|
+
let body;
|
|
1571
|
+
try {
|
|
1572
|
+
body = await readJsonBody(req);
|
|
1573
|
+
}
|
|
1574
|
+
catch {
|
|
1575
|
+
text(res, 400, "bad json");
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
await handleLocalSendOtp(res, body);
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
if (route === "/local-auth/verify-otp" && req.method === "POST") {
|
|
1582
|
+
let body;
|
|
1583
|
+
try {
|
|
1584
|
+
body = await readJsonBody(req);
|
|
1585
|
+
}
|
|
1586
|
+
catch {
|
|
1587
|
+
text(res, 400, "bad json");
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
await handleLocalVerifyOtp(res, body);
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
if (route === "/local-auth/passphrase" && req.method === "POST") {
|
|
1594
|
+
let body;
|
|
1595
|
+
try {
|
|
1596
|
+
body = await readJsonBody(req);
|
|
1597
|
+
}
|
|
1598
|
+
catch {
|
|
1599
|
+
text(res, 400, "bad json");
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
await handleLocalPassphrase(res, body);
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1289
1605
|
if (route === "/stats" && req.method === "GET") {
|
|
1290
1606
|
if (!checkNonce(url.searchParams.get("nonce") || undefined))
|
|
1291
1607
|
return void text(res, 403, "bad nonce");
|
|
@@ -1500,10 +1816,17 @@ export function startCallbackServer(opts = {}) {
|
|
|
1500
1816
|
/* already logged out locally */
|
|
1501
1817
|
}
|
|
1502
1818
|
connected = false;
|
|
1819
|
+
const revokedPendingCredential = await revokePendingLocalAuth();
|
|
1503
1820
|
stats = null;
|
|
1504
1821
|
migrateStarted = false;
|
|
1505
1822
|
progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
|
|
1506
|
-
json(res, 200, {
|
|
1823
|
+
json(res, 200, {
|
|
1824
|
+
ok: true,
|
|
1825
|
+
authUrl,
|
|
1826
|
+
switchAccountUrl: switchAccountUrl || authUrl,
|
|
1827
|
+
localOnly: true,
|
|
1828
|
+
revokedPendingCredential,
|
|
1829
|
+
});
|
|
1507
1830
|
Promise.resolve()
|
|
1508
1831
|
.then(() => logoutHandler?.())
|
|
1509
1832
|
.catch((e) => {
|
|
@@ -1627,6 +1950,34 @@ function authedAxios(token) {
|
|
|
1627
1950
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
1628
1951
|
});
|
|
1629
1952
|
}
|
|
1953
|
+
function apiAxios() {
|
|
1954
|
+
return axios.create({
|
|
1955
|
+
baseURL: API_BASE_URL,
|
|
1956
|
+
headers: { "Content-Type": "application/json" },
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
function normalizeEmail(value) {
|
|
1960
|
+
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
1961
|
+
}
|
|
1962
|
+
function validEmail(value) {
|
|
1963
|
+
return value.length > 0 && value.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
|
1964
|
+
}
|
|
1965
|
+
function acceptedLocalTerms(body) {
|
|
1966
|
+
return body.acceptedTerms === true && body.ageConfirmed === true;
|
|
1967
|
+
}
|
|
1968
|
+
function publicAxiosError(error, fallback) {
|
|
1969
|
+
if (axios.isAxiosError(error)) {
|
|
1970
|
+
const data = error.response?.data;
|
|
1971
|
+
const status = typeof error.response?.status === "number" ? error.response.status : 500;
|
|
1972
|
+
const message = typeof data?.error === "string"
|
|
1973
|
+
? data.error
|
|
1974
|
+
: typeof data?.message === "string"
|
|
1975
|
+
? data.message
|
|
1976
|
+
: fallback;
|
|
1977
|
+
return { message, status };
|
|
1978
|
+
}
|
|
1979
|
+
return { message: error instanceof Error ? error.message : fallback, status: 500 };
|
|
1980
|
+
}
|
|
1630
1981
|
function formatVerificationError(error) {
|
|
1631
1982
|
if (axios.isAxiosError(error)) {
|
|
1632
1983
|
const status = typeof error.response?.status === "number" ? error.response.status : null;
|
|
@@ -1693,16 +2044,20 @@ export async function verifyAndStore(input) {
|
|
|
1693
2044
|
return "Token saved, but this account is ENCRYPTED. Re-run with --passphrase to unlock the vault.";
|
|
1694
2045
|
}
|
|
1695
2046
|
store.saveKey(keyB64);
|
|
1696
|
-
return "✅ Token + encryption key verified
|
|
2047
|
+
return "✅ Token + encryption key verified. This device stays unlocked until you run `echomem-mcp lock` or log out. Retry EchoMem in this session — no editor restart needed.";
|
|
1697
2048
|
}
|
|
1698
2049
|
function prompt(question, { silent = false } = {}) {
|
|
1699
2050
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
1700
2051
|
return new Promise((resolve) => {
|
|
1701
2052
|
if (silent) {
|
|
1702
2053
|
const out = process.stdout;
|
|
1703
|
-
|
|
2054
|
+
// readline renders `question` through this private writer too. Print the label first, then
|
|
2055
|
+
// suppress only the user's characters; otherwise the CLI waits on a completely blank line.
|
|
2056
|
+
out.write(question);
|
|
2057
|
+
const silentRl = rl;
|
|
2058
|
+
silentRl._writeToOutput = () => undefined;
|
|
1704
2059
|
}
|
|
1705
|
-
rl.question(question, (answer) => {
|
|
2060
|
+
rl.question(silent ? "" : question, (answer) => {
|
|
1706
2061
|
rl.close();
|
|
1707
2062
|
if (silent)
|
|
1708
2063
|
process.stdout.write("\n");
|
|
@@ -1767,6 +2122,9 @@ async function cmdSetup(flags) {
|
|
|
1767
2122
|
if (!flags["no-agents-md"]) {
|
|
1768
2123
|
writeMemoryGuidanceForTargets(targets);
|
|
1769
2124
|
}
|
|
2125
|
+
if (!flags["no-codex-skills"]) {
|
|
2126
|
+
writeCodexSkillsForTargets(targets);
|
|
2127
|
+
}
|
|
1770
2128
|
console.log("");
|
|
1771
2129
|
if (flags["skip-login"] || flags["no-login"]) {
|
|
1772
2130
|
// init drives login itself right after, so the "skipped" note would be misleading there.
|
|
@@ -1781,10 +2139,10 @@ async function cmdSetup(flags) {
|
|
|
1781
2139
|
}
|
|
1782
2140
|
/**
|
|
1783
2141
|
* `echomem-mcp init` — the one-command install. Configures EVERY coding agent installed on this
|
|
1784
|
-
* machine (Codex + Claude Code + Claude Desktop, not just auto-detected ones),
|
|
1785
|
-
* memory guidance, logs in via the browser, and launches the
|
|
1786
|
-
* single command. `setup`/`update` remain the granular
|
|
1787
|
-
* defaults and frames the result.
|
|
2142
|
+
* machine (Codex + Claude Code + Claude Desktop, not just auto-detected ones), installs EchoMem's
|
|
2143
|
+
* Codex skills, writes the AGENTS.md memory guidance, logs in via the browser, and launches the
|
|
2144
|
+
* context HUD — the whole product in a single command. `setup`/`update` remain the granular
|
|
2145
|
+
* primitives; init just picks the "do everything" defaults and frames the result.
|
|
1788
2146
|
*/
|
|
1789
2147
|
async function cmdInit(flags) {
|
|
1790
2148
|
console.log("Setting up EchoMem — shared memory for all your coding agents, plus the live context HUD.\n");
|
|
@@ -1819,8 +2177,8 @@ async function cmdInit(flags) {
|
|
|
1819
2177
|
function writeMemoryGuidanceForTargets(targets) {
|
|
1820
2178
|
const files = new Map(); // path → label
|
|
1821
2179
|
for (const t of targets) {
|
|
1822
|
-
if (t.id === "codex")
|
|
1823
|
-
files.set(
|
|
2180
|
+
if (t.id === "codex" && t.kind === "command")
|
|
2181
|
+
files.set(path.join(t.detectDir, "AGENTS.md"), "Codex");
|
|
1824
2182
|
if (t.id === "claude-code" || t.id === "claude-desktop")
|
|
1825
2183
|
files.set(home(".claude", "CLAUDE.md"), "Claude");
|
|
1826
2184
|
}
|
|
@@ -1838,6 +2196,24 @@ function writeMemoryGuidanceForTargets(targets) {
|
|
|
1838
2196
|
}
|
|
1839
2197
|
}
|
|
1840
2198
|
}
|
|
2199
|
+
function writeCodexSkillsForTargets(targets) {
|
|
2200
|
+
const target = targets.find((client) => client.id === "codex" && client.kind === "command");
|
|
2201
|
+
if (!target)
|
|
2202
|
+
return;
|
|
2203
|
+
try {
|
|
2204
|
+
const report = installCodexSkills(target.detectDir);
|
|
2205
|
+
const changed = [...report.installed, ...report.updated];
|
|
2206
|
+
if (changed.length > 0) {
|
|
2207
|
+
console.log(`✅ Installed EchoMem Codex skills: ${CODEX_SKILL_NAMES.join(", ")} — start a new Codex session to load them.`);
|
|
2208
|
+
}
|
|
2209
|
+
if (report.competingMemorySkills.length > 0) {
|
|
2210
|
+
console.log(`ℹ️ Other memory skills remain installed: ${report.competingMemorySkills.join(", ")}. EchoMem did not modify them; its global guidance now selects EchoMem by default.`);
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
catch (error) {
|
|
2214
|
+
console.log(`ℹ️ Could not install EchoMem Codex skills: ${error instanceof Error ? error.message : String(error)}`);
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
1841
2217
|
async function cmdUpdate(flags) {
|
|
1842
2218
|
await cmdSetup({ ...flags, "skip-login": true });
|
|
1843
2219
|
console.log(`Update config complete. Start a new MCP session to load ${MCP_PACKAGE_LABEL}.`);
|
|
@@ -1915,9 +2291,9 @@ async function cmdLogin(flags) {
|
|
|
1915
2291
|
process.exitCode = 1;
|
|
1916
2292
|
return ok;
|
|
1917
2293
|
}
|
|
1918
|
-
// Browser path: open a localhost dashboard.
|
|
1919
|
-
//
|
|
1920
|
-
console.log("Opening your browser to
|
|
2294
|
+
// Browser path: open a localhost dashboard. Login, legal confirmation, and encryption
|
|
2295
|
+
// passphrase entry all happen through this local bridge. The nonce gates every local route.
|
|
2296
|
+
console.log("Opening your browser to connect this device locally…");
|
|
1921
2297
|
const devPortRaw = typeof flags["dev-port"] === "string" ? Number(flags["dev-port"]) : undefined;
|
|
1922
2298
|
if (devPortRaw !== undefined && (!Number.isInteger(devPortRaw) || devPortRaw < 1024 || devPortRaw > 65535)) {
|
|
1923
2299
|
throw new Error("--dev-port must be an integer between 1024 and 65535");
|
|
@@ -1989,18 +2365,10 @@ async function cmdLogin(flags) {
|
|
|
1989
2365
|
startForensicScan();
|
|
1990
2366
|
},
|
|
1991
2367
|
});
|
|
1992
|
-
const callbackUrl = `http://127.0.0.1:${srv.port}/callback`;
|
|
1993
2368
|
const localSetupUrl = `http://127.0.0.1:${srv.port}/setup?nonce=${nonce}`;
|
|
1994
|
-
const connectUrl = `${WEB_URL}/connect-device?callback=${encodeURIComponent(callbackUrl)}&nonce=${nonce}&return_to=${encodeURIComponent(localSetupUrl)}`;
|
|
1995
|
-
const switchAccountUrl = new URL(connectUrl);
|
|
1996
|
-
// The hosted connect-device page should clear its own Supabase/browser session before minting
|
|
1997
|
-
// the localhost token when this hint is present. Localhost cannot safely clear yeahecho.com auth.
|
|
1998
|
-
switchAccountUrl.searchParams.set("force_signout", "1");
|
|
1999
|
-
switchAccountUrl.searchParams.set("prompt", "login");
|
|
2000
|
-
srv.setAuthUrl(connectUrl, switchAccountUrl.toString());
|
|
2001
2369
|
openBrowser(localSetupUrl);
|
|
2002
2370
|
console.log(`If it didn't open, visit:\n ${localSetupUrl}\n`);
|
|
2003
|
-
console.log("Waiting for
|
|
2371
|
+
console.log("Waiting for local setup for up to 15 minutes…");
|
|
2004
2372
|
const startForensicScan = () => {
|
|
2005
2373
|
if (forensicScanStarted || forensicConsent !== "allowed")
|
|
2006
2374
|
return;
|
|
@@ -2654,7 +3022,9 @@ async function cmdUnlock(flags) {
|
|
|
2654
3022
|
let passphrase = typeof flags.passphrase === "string" ? flags.passphrase : undefined;
|
|
2655
3023
|
const key = typeof flags.key === "string" ? flags.key : undefined;
|
|
2656
3024
|
if (!passphrase && !key) {
|
|
2657
|
-
|
|
3025
|
+
console.log("This is a private, one-time unlock for this trusted device.");
|
|
3026
|
+
console.log("Enter your vault passphrase below. Your typing is hidden; press Return when finished.");
|
|
3027
|
+
passphrase = await prompt("Vault passphrase (typing is hidden): ", { silent: true });
|
|
2658
3028
|
}
|
|
2659
3029
|
const keyB64 = passphrase ? await deriveAndVerifyKey(passphrase, config) : key && (await verifyKeyB64(key, config)) ? key : null;
|
|
2660
3030
|
if (!keyB64) {
|
|
@@ -2663,7 +3033,22 @@ async function cmdUnlock(flags) {
|
|
|
2663
3033
|
return;
|
|
2664
3034
|
}
|
|
2665
3035
|
store.saveKey(keyB64);
|
|
2666
|
-
console.log("✅ Vault unlocked.
|
|
3036
|
+
console.log("✅ Vault unlocked. This device stays unlocked until you run `echomem-mcp lock` or log out.");
|
|
3037
|
+
console.log("Retry the EchoMem action in your current agent session — no restart needed.");
|
|
3038
|
+
}
|
|
3039
|
+
function cmdLock() {
|
|
3040
|
+
if (process.env.ECHO_ENCRYPTION_KEY) {
|
|
3041
|
+
console.error("The vault key comes from ECHO_ENCRYPTION_KEY. Unset that environment variable to lock this device.");
|
|
3042
|
+
process.exitCode = 1;
|
|
3043
|
+
return;
|
|
3044
|
+
}
|
|
3045
|
+
const store = new KeyStore();
|
|
3046
|
+
if (!store.getKey()) {
|
|
3047
|
+
console.log("EchoMem vault is already locked.");
|
|
3048
|
+
return;
|
|
3049
|
+
}
|
|
3050
|
+
store.clearKey();
|
|
3051
|
+
console.log("🔒 EchoMem vault locked on this device. Your login remains connected.");
|
|
2667
3052
|
}
|
|
2668
3053
|
async function cmdStatus(flags = {}) {
|
|
2669
3054
|
const store = new KeyStore();
|
|
@@ -2700,7 +3085,13 @@ async function cmdStatus(flags = {}) {
|
|
|
2700
3085
|
console.log(`Logged in as: (could not verify — ${formatVerificationError(error)})`);
|
|
2701
3086
|
}
|
|
2702
3087
|
}
|
|
2703
|
-
|
|
3088
|
+
const key = store.getKey();
|
|
3089
|
+
const keyStatus = key
|
|
3090
|
+
? process.env.ECHO_ENCRYPTION_KEY
|
|
3091
|
+
? "present — provided by environment"
|
|
3092
|
+
: "present — trusted on this device until `echomem-mcp lock` or logout"
|
|
3093
|
+
: "not set — open Terminal and run `echomem-mcp unlock` for encrypted accounts";
|
|
3094
|
+
console.log(`Encryption key: ${keyStatus}`);
|
|
2704
3095
|
const detected = detectClients();
|
|
2705
3096
|
console.log(`Detected clients: ${detected.length ? detected.map((c) => c.label).join(", ") : "none auto-detected"}`);
|
|
2706
3097
|
const reports = inspectClientConfigs(latest ?? MCP_PACKAGE_VERSION);
|
|
@@ -2729,11 +3120,13 @@ Usage:
|
|
|
2729
3120
|
echomem-mcp Run the MCP server (stdio; default — used by your editor)
|
|
2730
3121
|
echomem-mcp setup [--client X] Detect editor, write its MCP config, then log in
|
|
2731
3122
|
echomem-mcp setup --skip-login Write MCP config without opening login/browser
|
|
3123
|
+
echomem-mcp setup --no-codex-skills Skip installing the bundled EchoMem Codex skills
|
|
2732
3124
|
echomem-mcp update --all Repoint detected clients to this installed bridge; no login/browser
|
|
2733
3125
|
echomem-mcp update --client X Repoint one MCP client; no login/browser
|
|
2734
3126
|
echomem-mcp setup --with-hud Configure MCP, then launch the EchoMem context HUD
|
|
2735
|
-
echomem-mcp login
|
|
2736
|
-
echomem-mcp unlock
|
|
3127
|
+
echomem-mcp login Connect this device in the local browser page (or --token/--passphrase)
|
|
3128
|
+
echomem-mcp unlock Privately unlock the vault on this trusted device
|
|
3129
|
+
echomem-mcp lock Remove the local vault key while keeping the device login
|
|
2737
3130
|
echomem-mcp status Show token/key/clients
|
|
2738
3131
|
echomem-mcp doctor [--no-network] Diagnose configured client bridge versions
|
|
2739
3132
|
echomem-mcp logout Remove stored credentials
|
|
@@ -2777,6 +3170,9 @@ export async function runCli(argv) {
|
|
|
2777
3170
|
case "unlock":
|
|
2778
3171
|
await cmdUnlock(flags);
|
|
2779
3172
|
return true;
|
|
3173
|
+
case "lock":
|
|
3174
|
+
cmdLock();
|
|
3175
|
+
return true;
|
|
2780
3176
|
case "status":
|
|
2781
3177
|
await cmdStatus(flags);
|
|
2782
3178
|
return true;
|