memro-cli 0.1.1 → 0.1.5

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/src/cli.js CHANGED
@@ -1,10 +1,12 @@
1
- import { execFile } from "node:child_process";
1
+ import { execFile, execFileSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
 
6
6
  import { CLIENTS, findClient } from "./clients.js";
7
- import { pick } from "./picker.js";
7
+ import { pick, pickOne, promptText } from "./picker.js";
8
+ import { installInstructions, uninstallInstructions } from "./instructions.js";
9
+ import * as ui from "./ui.js";
8
10
 
9
11
  const ENVIRONMENTS = {
10
12
  prod: "https://api.memro.co",
@@ -24,28 +26,65 @@ Usage
24
26
  npx memro-cli disconnect Remove Memro from your tools and forget the key
25
27
 
26
28
  Options
27
- --client <ids> Only these tools, comma-separated, skipping the list (${CLIENTS.map((c) => c.id).join(", ")})
29
+ --project Configure the current project instead of global tools
30
+ --scope <user|project> Configuration scope: "user" (default) or "project"
31
+ --client <ids> Only these tools, comma-separated (${CLIENTS.map((c) => c.id).join(", ")})
32
+ --key <key> Use a specific API key (memro_sk_...) instead of saved credentials
28
33
  --yes, -y Don't ask: use every detected tool
34
+ --no-instructions Don't add the "use Memro automatically" note to
35
+ the tools' instructions (CLAUDE.md, AGENTS.md…)
29
36
  --staging | --local Use staging or a local server instead of Memro Cloud
30
37
  --api <url> Use a specific Memro API (self-hosted)
31
38
  `;
32
39
 
33
- function parseArgs(argv) {
34
- const opts = { command: null, clients: null, yes: false, api: null };
40
+ export function parseArgs(argv) {
41
+ const opts = { command: null, clients: null, yes: false, api: null, instructions: true, scope: null, key: null };
42
+ let projectFlag = false;
43
+ let scopeFlagValue = null;
44
+
35
45
  for (let i = 0; i < argv.length; i++) {
36
46
  const a = argv[i];
37
- if (a === "--client" || a === "--clients") opts.clients = (argv[++i] || "").split(",").map((s) => s.trim()).filter(Boolean);
38
- else if (a === "--yes" || a === "-y") opts.yes = true;
39
- else if (a === "--staging") opts.api = ENVIRONMENTS.staging;
40
- else if (a === "--local") opts.api = ENVIRONMENTS.local;
41
- else if (a === "--api") {
47
+ if (a === "--client" || a === "--clients") {
48
+ opts.clients = (argv[++i] || "").split(",").map((s) => s.trim()).filter(Boolean);
49
+ } else if (a === "--yes" || a === "-y") {
50
+ opts.yes = true;
51
+ } else if (a === "--no-instructions") {
52
+ opts.instructions = false;
53
+ } else if (a === "--project") {
54
+ projectFlag = true;
55
+ } else if (a === "--scope") {
56
+ const val = (argv[++i] || "").toLowerCase().trim();
57
+ if (!val || val.startsWith("-")) throw new Error("--scope requires a value ('user' or 'project')");
58
+ if (val !== "user" && val !== "project") throw new Error(`Invalid scope "${val}". Must be "user" or "project".`);
59
+ if (scopeFlagValue && scopeFlagValue !== val) {
60
+ throw new Error(`Conflicting scope arguments: specified both "--scope ${scopeFlagValue}" and "--scope ${val}".`);
61
+ }
62
+ scopeFlagValue = val;
63
+ } else if (a === "--key") {
64
+ opts.key = argv[++i];
65
+ if (!opts.key || opts.key.startsWith("-")) throw new Error("--key requires an API key (memro_sk_...)");
66
+ } else if (a === "--staging") {
67
+ opts.api = ENVIRONMENTS.staging;
68
+ } else if (a === "--local") {
69
+ opts.api = ENVIRONMENTS.local;
70
+ } else if (a === "--api") {
42
71
  opts.api = argv[++i];
43
72
  if (!opts.api || opts.api.startsWith("-")) throw new Error("--api needs a URL, e.g. --api https://memro.example.com");
73
+ } else if (a === "--help" || a === "-h") {
74
+ opts.command = "help";
75
+ } else if (!opts.command) {
76
+ opts.command = a;
77
+ } else {
78
+ throw new Error(`Unexpected argument: ${a}`);
44
79
  }
45
- else if (a === "--help" || a === "-h") opts.command = "help";
46
- else if (!opts.command) opts.command = a;
47
- else throw new Error(`Unexpected argument: ${a}`);
48
80
  }
81
+
82
+ if (projectFlag && scopeFlagValue === "user") {
83
+ throw new Error('Conflicting scope arguments: cannot combine "--project" with "--scope user".');
84
+ }
85
+
86
+ opts.scopeExplicit = projectFlag || Boolean(scopeFlagValue);
87
+ opts.scope = projectFlag ? "project" : (scopeFlagValue || "user");
49
88
  opts.api = checkURL((opts.api || process.env.MEMRO_API_URL || ENVIRONMENTS.prod).replace(/\/+$/, ""), "API");
50
89
  return opts;
51
90
  }
@@ -95,14 +134,15 @@ async function deviceLogin(api) {
95
134
  if (!start.ok) throw new Error(`Couldn't start sign-in: ${start.data?.message || start.status}`);
96
135
  const { device_code, user_code, verification_uri_complete, expires_in, interval } = start.data;
97
136
 
98
- console.log(`\n Your code: \x1b[1m${user_code}\x1b[0m`);
99
- console.log(` Opening ${verification_uri_complete}`);
100
- console.log(" Sign in there and press Connect. (If the browser didn't open, visit the link.)\n");
137
+ ui.info("Sign in to Memro");
138
+ ui.line(`Your code: ${ui.c.bold(user_code)}`);
139
+ ui.line(ui.c.dim(`Opening ${verification_uri_complete}`));
140
+ ui.line(ui.c.dim("Approve it there. If the browser didn't open, visit the link."));
101
141
  openBrowser(verification_uri_complete);
102
142
 
103
143
  const deadline = Date.now() + (expires_in || 600) * 1000;
104
144
  let wait = (interval || 3) * 1000;
105
- process.stdout.write(" Waiting for approval…");
145
+ process.stdout.write(`${ui.bar()} ${ui.c.dim("Waiting for approval…")}`);
106
146
  while (Date.now() < deadline) {
107
147
  await sleep(wait);
108
148
  let res;
@@ -112,7 +152,7 @@ async function deviceLogin(api) {
112
152
  continue; // a dropped connection: keep polling until the code expires
113
153
  }
114
154
  if (res.ok) {
115
- process.stdout.write(" approved.\n");
155
+ process.stdout.write(` ${ui.c.green("approved")}\n`);
116
156
  checkURL(res.data.mcp_url, "MCP");
117
157
  return res.data;
118
158
  }
@@ -139,11 +179,108 @@ function loadCredentials() {
139
179
  try { return JSON.parse(fs.readFileSync(CREDENTIALS(), "utf8")); } catch { return null; }
140
180
  }
141
181
 
142
- function selectClients(ids) {
143
- if (!ids) return CLIENTS.filter((c) => c.detect());
182
+ export function isGitTracked(dir = process.cwd()) {
183
+ try {
184
+ return fs.existsSync(path.join(dir, ".git")) ||
185
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: dir, stdio: "ignore" }) !== null;
186
+ } catch {
187
+ return false;
188
+ }
189
+ }
190
+
191
+ /** The home folder or the filesystem root: never a project. */
192
+ export function isHomeOrRoot(dir) {
193
+ // Compare real paths: git reports /private/var/… where HOME says /var/…,
194
+ // and C:/Users/name where Windows may give the 8.3 C:\Users\NAME~1. The
195
+ // native realpath expands short names; Windows paths ignore case.
196
+ const real = (p) => {
197
+ let r;
198
+ try { r = fs.realpathSync.native(path.resolve(p)); } catch { r = path.resolve(p); }
199
+ return process.platform === "win32" ? r.toLowerCase() : r;
200
+ };
201
+ const d = real(dir);
202
+ return d === real(os.homedir()) || d === real(path.parse(d).root);
203
+ }
204
+
205
+ export function resolveProjectRoot(startDir = process.cwd()) {
206
+ try {
207
+ const toplevel = execFileSync("git", ["rev-parse", "--show-toplevel"], {
208
+ cwd: startDir,
209
+ encoding: "utf8",
210
+ stdio: ["ignore", "pipe", "ignore"],
211
+ }).trim();
212
+ if (toplevel && fs.existsSync(toplevel) && !isHomeOrRoot(toplevel)) {
213
+ return toplevel;
214
+ }
215
+ } catch {
216
+ // Git not available or not inside a work tree
217
+ }
218
+
219
+ // Walk up checking for project boundary markers
220
+ let curr = path.resolve(startDir);
221
+ const homeDir = path.resolve(os.homedir());
222
+ while (true) {
223
+ // Stop if we reach user's home directory: home folder is never a project root
224
+ if (curr === homeDir) break;
225
+
226
+ if (
227
+ fs.existsSync(path.join(curr, ".git")) ||
228
+ fs.existsSync(path.join(curr, "CLAUDE.md")) ||
229
+ fs.existsSync(path.join(curr, "package.json")) ||
230
+ fs.existsSync(path.join(curr, "go.mod")) ||
231
+ fs.existsSync(path.join(curr, "Cargo.toml")) ||
232
+ fs.existsSync(path.join(curr, "pyproject.toml"))
233
+ ) {
234
+ return curr;
235
+ }
236
+ const parent = path.dirname(curr);
237
+ if (parent === curr) break;
238
+ curr = parent;
239
+ }
240
+
241
+ return path.resolve(startDir);
242
+ }
243
+
244
+ export function ensureGitIgnored(filePath, projectRoot) {
245
+ const rel = path.relative(projectRoot, filePath);
246
+ const pattern = "/" + rel.split(path.sep).join("/");
247
+
248
+ // Check if git considers it ignored already
249
+ try {
250
+ execFileSync("git", ["check-ignore", "-q", rel], { cwd: projectRoot, stdio: "ignore" });
251
+ return { ignored: true, added: false };
252
+ } catch {
253
+ // Not ignored or git not found
254
+ }
255
+
256
+ // Check if .gitignore already has this pattern
257
+ const gitignorePath = path.join(projectRoot, ".gitignore");
258
+ try {
259
+ const text = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf8") : "";
260
+ const lines = text.split("\n").map((l) => l.trim());
261
+ if (lines.includes(pattern) || lines.includes(pattern.slice(1)) || lines.includes(pattern + "/") || lines.includes(pattern.slice(1) + "/")) {
262
+ return { ignored: true, added: false };
263
+ }
264
+ const dirPart = rel.split(path.sep)[0];
265
+ if (lines.includes(`/${dirPart}/`) || lines.includes(`/${dirPart}`) || lines.includes(`${dirPart}/`) || lines.includes(dirPart)) {
266
+ return { ignored: true, added: false };
267
+ }
268
+
269
+ const addition = (text && !text.endsWith("\n") ? "\n" : "") +
270
+ `# Memro MCP configuration (contains API key)\n${pattern}\n`;
271
+ fs.writeFileSync(gitignorePath, text + addition);
272
+ return { ignored: true, added: true };
273
+ } catch {
274
+ return { ignored: false, added: false };
275
+ }
276
+ }
277
+
278
+ function selectClients(ids, scope = "user", cwd = process.cwd()) {
279
+ if (!ids) return CLIENTS.filter((c) => c.detect({ scope, cwd }) && c.supportsScope(scope));
144
280
  return ids.map((id) => {
145
281
  const c = findClient(id);
146
282
  if (!c) throw new Error(`Unknown client "${id}". Known: ${CLIENTS.map((x) => x.id).join(", ")}`);
283
+ if (!c.supportsScope(scope)) throw new Error(`${c.name} does not support ${scope} scope.`);
147
284
  return c;
148
285
  });
149
286
  }
@@ -153,111 +290,231 @@ function selectClients(ids) {
153
290
  * otherwise the user picks in a list with detected tools pre-selected.
154
291
  * Returns null when the user cancels or nothing can be asked.
155
292
  */
156
- async function chooseTargets(opts) {
157
- if (opts.clients) return selectClients(opts.clients);
158
- const detected = new Set(CLIENTS.filter((c) => c.detect()));
293
+ async function chooseTargets(opts, cwd = process.cwd()) {
294
+ const scope = opts.scope || "user";
295
+ const supported = CLIENTS.filter((c) => c.supportsScope(scope));
296
+ if (opts.clients) return selectClients(opts.clients, scope, cwd);
297
+ const detected = new Set(supported.filter((c) => c.detect({ scope, cwd })));
159
298
  if (opts.yes) return [...detected];
160
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
161
- console.log(" Not a terminal: pass --yes (every detected tool) or --client <ids>.");
299
+ if (!interactive()) {
300
+ ui.warn("Not a terminal: pass --yes (every detected tool) or --client <ids>.");
162
301
  return null;
163
302
  }
164
- console.log("");
303
+ // Installed tools first, then the rest, each in catalogue order.
304
+ const ordered = [...supported.filter((c) => detected.has(c)), ...supported.filter((c) => !detected.has(c))];
165
305
  const picked = await pick(
166
- CLIENTS.map((c) => ({ label: c.name, hint: detected.has(c) ? "" : "not detected", checked: detected.has(c), client: c })),
167
- { title: "Which tools should use Memro?" },
306
+ [
307
+ ...ordered.map((c) => ({ label: c.name, hint: detected.has(c) ? "" : "not installed", checked: detected.has(c), client: c })),
308
+ {
309
+ label: "Teach tools to use Memro automatically",
310
+ hint: "notes in CLAUDE.md, AGENTS.md…",
311
+ summary: "+ instructions",
312
+ checked: opts.instructions,
313
+ option: "instructions",
314
+ },
315
+ ],
316
+ { title: scope === "project" ? "Which tools should use Memro in this project?" : "Which tools should use Memro?" },
168
317
  );
169
- return picked && picked.map((p) => p.client);
318
+ if (!picked) return null;
319
+ opts.instructions = picked.some((p) => p.option === "instructions");
320
+ return picked.filter((p) => p.client).map((p) => p.client);
170
321
  }
171
322
 
323
+ const interactive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
324
+ const host = (url) => url.replace(/^https?:\/\//, "");
325
+
172
326
  async function connect(opts) {
173
- console.log(`\n◆ Memro connect (${opts.api})`);
174
- // Choose first, so cancelling never creates an API key.
175
- const targets = await chooseTargets(opts);
176
- if (!targets) {
177
- console.log(" Nothing changed.");
178
- return;
327
+ ui.intro("Memro connect", host(opts.api));
328
+ ui.gap();
329
+
330
+ // Ask where to set up unless a flag already said so.
331
+ if (!opts.scopeExplicit && interactive() && !opts.yes && !opts.clients) {
332
+ const here = resolveProjectRoot(process.cwd());
333
+ const scopeChoice = await pickOne(
334
+ [
335
+ { label: "All my projects", hint: "Adds Memro to your tools globally", summary: "All projects (global)", value: "user" },
336
+ {
337
+ label: "Only this project",
338
+ hint: isHomeOrRoot(here) ? "Run this inside a project folder to use it" : tilde(here),
339
+ summary: `This project · ${tilde(here)}`,
340
+ value: "project",
341
+ },
342
+ ],
343
+ { title: "Where should Memro be set up?" },
344
+ );
345
+ if (!scopeChoice) return ui.cancelled();
346
+ opts.scope = scopeChoice.value;
179
347
  }
180
- if (targets.length === 0 && !opts.yes) {
181
- console.log(" No tools selected. Nothing changed.");
182
- return;
348
+
349
+ const scope = opts.scope || "user";
350
+ const cwd = scope === "project" ? resolveProjectRoot(process.cwd()) : process.cwd();
351
+
352
+ if (scope === "project") {
353
+ if (isHomeOrRoot(cwd)) {
354
+ ui.warn(`${tilde(cwd)} is not a project folder.`);
355
+ ui.line("Run this inside a project, or choose “All my projects”.");
356
+ return ui.outro("Nothing changed.");
357
+ }
358
+ if (opts.scopeExplicit) ui.answered("Project", tilde(cwd));
359
+
360
+ // Ask about memory isolation if no key was passed and terminal is interactive
361
+ if (!opts.key && !process.env.MEMRO_API_KEY && interactive() && !opts.yes) {
362
+ const memoryChoice = await pickOne(
363
+ [
364
+ {
365
+ label: "Shared with my account",
366
+ hint: "Same memory as your other projects and tools",
367
+ summary: "Shared with your account",
368
+ value: "shared",
369
+ },
370
+ {
371
+ label: "Separate for this project",
372
+ hint: "Use a dedicated API key; only this project sees its memory",
373
+ summary: "Separate (project API key)",
374
+ value: "isolated",
375
+ },
376
+ ],
377
+ { title: "Which memory should this project use?" },
378
+ );
379
+ if (!memoryChoice) return ui.cancelled();
380
+ if (memoryChoice.value === "isolated") {
381
+ const enteredKey = await promptText(`${ui.bar()} Project API key (memro_sk_…): `);
382
+ if (!enteredKey) return ui.cancelled("No key entered. Nothing changed.");
383
+ opts.key = enteredKey;
384
+ ui.gap();
385
+ }
386
+ }
183
387
  }
184
388
 
185
- let creds = loadCredentials();
186
- if (creds?.api_url === opts.api) {
187
- const who = await request(opts.api, "/auth/whoami", { key: creds.api_key });
188
- if (who.status === 401 || who.status === 403) creds = null;
189
- // Anything else (an outage, a rate limit) says nothing about the key;
190
- // signing in again would mint a second one.
191
- else if (!who.ok) throw new Error(`Memro API returned ${who.status}. Try again in a moment.`);
192
- else console.log(` Already signed in as agent ${creds.agent_id.slice(0, 12)}…`);
389
+ // Choose first, so cancelling never creates an API key.
390
+ const targets = await chooseTargets(opts, cwd);
391
+ if (!targets) return ui.cancelled();
392
+ if (targets.length === 0 && !opts.yes) return ui.cancelled("No tools selected. Nothing changed.");
393
+
394
+ let creds = null;
395
+ const providedKey = opts.key || process.env.MEMRO_API_KEY;
396
+ if (providedKey) {
397
+ const who = await request(opts.api, "/auth/whoami", { key: providedKey });
398
+ if (!who.ok) {
399
+ throw new Error(`The provided API key is not valid (${who.status}) on ${opts.api}`);
400
+ }
401
+ const agentId = who.data?.agent_id || who.data?.id || "unknown";
402
+ const mcpUrl = opts.api === ENVIRONMENTS.prod ? "https://mcp.memro.co/mcp" : `${opts.api}/mcp`;
403
+ creds = { api_url: opts.api, mcp_url: mcpUrl, agent_id: agentId, api_key: providedKey };
404
+ ui.info(`Using the project key ${ui.c.dim(`(agent ${agentId.slice(0, 12)}…)`)}`);
193
405
  } else {
194
- creds = null;
195
- }
196
- if (!creds) {
197
- const res = await deviceLogin(opts.api);
198
- creds = { api_url: opts.api, mcp_url: res.mcp_url, agent_id: res.agent_id, api_key: res.api_key };
199
- saveCredentials(creds);
406
+ creds = loadCredentials();
407
+ if (creds?.api_url === opts.api) {
408
+ const who = await request(opts.api, "/auth/whoami", { key: creds.api_key });
409
+ if (who.status === 401 || who.status === 403) creds = null;
410
+ // Anything else (an outage, a rate limit) says nothing about the key;
411
+ // signing in again would mint a second one.
412
+ else if (!who.ok) throw new Error(`Memro API returned ${who.status}. Try again in a moment.`);
413
+ else ui.info(`Signed in ${ui.c.dim(`(agent ${creds.agent_id.slice(0, 12)}…)`)}`);
414
+ } else {
415
+ creds = null;
416
+ }
417
+ if (!creds) {
418
+ const res = await deviceLogin(opts.api);
419
+ creds = { api_url: opts.api, mcp_url: res.mcp_url, agent_id: res.agent_id, api_key: res.api_key };
420
+ saveCredentials(creds);
421
+ }
200
422
  }
423
+ ui.gap();
201
424
 
202
425
  if (targets.length === 0) {
203
- console.log("\n No supported tools found on this machine.");
426
+ ui.warn("No supported tools found for this setup.");
204
427
  printManual(creds);
205
- return;
428
+ return ui.outro("Nothing else changed.");
206
429
  }
207
- console.log(`\n Adding Memro to: ${targets.map((c) => c.name).join(", ")}`);
208
430
 
431
+ ui.info(scope === "project" ? `Adding Memro to this project` : "Adding Memro to your tools");
209
432
  const conn = { mcpUrl: creds.mcp_url, apiKey: creds.api_key };
433
+ const width = Math.max(...targets.map((c) => c.name.length)) + 2;
434
+ const gitTracked = scope === "project" && isGitTracked(cwd);
210
435
  let ok = 0;
211
436
  for (const c of targets) {
212
437
  try {
213
- c.add(conn);
438
+ c.add(conn, { scope, cwd });
214
439
  ok++;
215
- console.log(` \x1b[32m✔\x1b[0m ${c.name.padEnd(15)} ${tilde(c.file())}`);
440
+ const configFile = c.file(scope, cwd);
441
+ let note = "";
442
+ if (gitTracked && ensureGitIgnored(configFile, cwd).added) note = "added to .gitignore (holds your key)";
443
+ ui.ok(c.name.padEnd(width), `${formatPath(configFile, scope, cwd)}${note ? " " + note : ""}`);
444
+ if (opts.instructions && c.rules) {
445
+ const rulesFile = c.rules(scope, cwd);
446
+ if (rulesFile) {
447
+ const result = installInstructions(rulesFile, { scope });
448
+ const what = result === "kept-own" ? "already mentions Memro" : "use Memro automatically";
449
+ ui.ok("".padEnd(width), `${formatPath(rulesFile, scope, cwd)} ${what}`);
450
+ }
451
+ }
216
452
  } catch (err) {
217
- console.log(` \x1b[33m!\x1b[0m ${c.name.padEnd(15)} ${err.message}`);
453
+ ui.fail(c.name.padEnd(width), err.message);
218
454
  }
219
455
  }
220
- console.log(`\n Done. Restart ${ok === 1 ? "the tool" : "your tools"} to load Memro's memory tools.`);
221
- console.log(" Manage or revoke the key anytime in the dashboard → API keys.\n");
456
+ ui.gap();
457
+ ui.outro(
458
+ ok ? `Done. Restart ${ok === 1 ? "the tool" : "your tools"} to load Memro.` : "Nothing was configured.",
459
+ "Manage or revoke the key in the dashboard → API keys.",
460
+ );
222
461
  }
223
462
 
224
463
  function printManual(creds) {
225
- console.log("\n Add this MCP server to your tool manually:");
226
- console.log(` URL: ${creds.mcp_url}`);
227
- console.log(" Header: Authorization: Bearer <the key in ~/.memro/credentials.json>\n");
464
+ ui.line("Add this MCP server to your tool by hand:");
465
+ ui.line(`URL ${creds.mcp_url}`);
466
+ ui.line("Header Authorization: Bearer <the key in ~/.memro/credentials.json>");
467
+ ui.gap();
228
468
  }
229
469
 
230
470
  async function status(opts) {
471
+ const scope = opts.scope || "user";
472
+ const cwd = scope === "project" ? resolveProjectRoot(process.cwd()) : process.cwd();
231
473
  const creds = loadCredentials();
232
- if (!creds) {
474
+ const activeKey = opts.key || process.env.MEMRO_API_KEY || creds?.api_key;
475
+ if (!creds && !activeKey) {
233
476
  console.log("Not connected. Run `npx memro-cli connect`.");
234
477
  return;
235
478
  }
236
- const who = await request(creds.api_url, "/auth/whoami", { key: creds.api_key });
237
- console.log(`API: ${creds.api_url}`);
238
- console.log(`MCP: ${creds.mcp_url}`);
239
- console.log(`Agent: ${creds.agent_id}`);
479
+ const apiUrl = creds?.api_url || opts.api;
480
+ const who = await request(apiUrl, "/auth/whoami", { key: activeKey });
481
+ console.log(`API: ${apiUrl}`);
482
+ console.log(`MCP: ${creds?.mcp_url || (apiUrl === ENVIRONMENTS.prod ? "https://mcp.memro.co/mcp" : `${apiUrl}/mcp`)}`);
483
+ console.log(`Agent: ${who.data?.agent_id || creds?.agent_id || "unknown"}`);
240
484
  console.log(`Key: ${who.ok ? "valid" : `not valid (${who.status}) — run \`npx memro-cli connect\``}`);
241
- const configured = CLIENTS.filter((c) => {
485
+ console.log(`Scope: ${scope}`);
486
+ if (scope === "project") {
487
+ console.log(`Root: ${cwd}`);
488
+ }
489
+ const supported = CLIENTS.filter((c) => c.supportsScope(scope));
490
+ const configured = supported.filter((c) => {
242
491
  try {
243
- const text = fs.existsSync(c.file()) ? fs.readFileSync(c.file(), "utf8") : "";
244
- return text.includes(creds.mcp_url);
492
+ const targetFile = c.file(scope, cwd);
493
+ const text = fs.existsSync(targetFile) ? fs.readFileSync(targetFile, "utf8") : "";
494
+ return text.includes("memro");
245
495
  } catch { return false; }
246
496
  });
247
497
  console.log(`Tools: ${configured.map((c) => c.name).join(", ") || "none"}`);
248
- void opts;
249
498
  }
250
499
 
251
500
  async function disconnect(opts) {
252
- const targets = opts.clients ? selectClients(opts.clients) : CLIENTS;
501
+ const scope = opts.scope || "user";
502
+ const cwd = scope === "project" ? resolveProjectRoot(process.cwd()) : process.cwd();
503
+ const targets = opts.clients ? selectClients(opts.clients, scope, cwd) : CLIENTS.filter((c) => c.supportsScope(scope));
253
504
  for (const c of targets) {
254
505
  try {
255
- if (c.remove()) console.log(` ✔ Removed from ${c.name}`);
506
+ if (c.remove({ scope, cwd })) console.log(` ✔ Removed from ${c.name} (${scope})`);
507
+ if (c.rules) {
508
+ const rulesFile = c.rules(scope, cwd);
509
+ if (rulesFile && uninstallInstructions(rulesFile)) {
510
+ console.log(` ✔ Removed the Memro note from ${formatPath(rulesFile, scope, cwd)}`);
511
+ }
512
+ }
256
513
  } catch (err) {
257
514
  console.log(` ! ${c.name}: ${err.message}`);
258
515
  }
259
516
  }
260
- if (!opts.clients && fs.existsSync(CREDENTIALS())) {
517
+ if (scope === "user" && !opts.clients && fs.existsSync(CREDENTIALS())) {
261
518
  fs.rmSync(CREDENTIALS());
262
519
  console.log(" ✔ Forgot the local key. Revoke it in the dashboard → API keys.");
263
520
  }
@@ -265,6 +522,14 @@ async function disconnect(opts) {
265
522
 
266
523
  const tilde = (p) => (p.startsWith(os.homedir()) ? "~" + p.slice(os.homedir().length) : p);
267
524
 
525
+ function formatPath(filePath, scope, cwd) {
526
+ if (scope === "project") {
527
+ const rel = path.relative(cwd, filePath);
528
+ return rel.startsWith("..") || path.isAbsolute(rel) ? tilde(filePath) : `./${rel}`;
529
+ }
530
+ return tilde(filePath);
531
+ }
532
+
268
533
  export async function main(argv) {
269
534
  const opts = parseArgs(argv);
270
535
  switch (opts.command) {