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/README.md +73 -20
- package/package.json +2 -2
- package/src/cli.js +340 -75
- package/src/clients.js +152 -55
- package/src/instructions.js +109 -0
- package/src/picker.js +115 -33
- package/src/ui.js +131 -0
package/src/clients.js
CHANGED
|
@@ -59,23 +59,42 @@ export function writeFileSafely(file, text) {
|
|
|
59
59
|
const writeJSON = (file, data) => writeFileSafely(file, JSON.stringify(data, null, 2) + "\n");
|
|
60
60
|
|
|
61
61
|
/** A client whose config is a JSON object with servers under `key`. */
|
|
62
|
-
function jsonClient({
|
|
62
|
+
function jsonClient({
|
|
63
|
+
id,
|
|
64
|
+
name,
|
|
65
|
+
aliases = [],
|
|
66
|
+
file,
|
|
67
|
+
detect,
|
|
68
|
+
key = "mcpServers",
|
|
69
|
+
entry,
|
|
70
|
+
rules,
|
|
71
|
+
supportsScope = () => true,
|
|
72
|
+
}) {
|
|
63
73
|
return {
|
|
64
74
|
id,
|
|
65
75
|
name,
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
76
|
+
aliases,
|
|
77
|
+
supportsScope,
|
|
78
|
+
file: (scope = "user", cwd = process.cwd()) => file(scope, cwd),
|
|
79
|
+
detect: (opts = {}) => {
|
|
80
|
+
const scope = typeof opts === "string" ? opts : (opts?.scope || "user");
|
|
81
|
+
const cwd = opts?.cwd || process.cwd();
|
|
82
|
+
return detect ? detect(scope, cwd) : false;
|
|
83
|
+
},
|
|
84
|
+
rules: rules ? (scope = "user", cwd = process.cwd()) => rules(scope, cwd) : () => null,
|
|
85
|
+
add(conn, { scope = "user", cwd = process.cwd() } = {}) {
|
|
86
|
+
const target = file(scope, cwd);
|
|
87
|
+
const cfg = readJSON(target);
|
|
70
88
|
cfg[key] = { ...(cfg[key] || {}), [SERVER_NAME]: entry(conn) };
|
|
71
|
-
writeJSON(
|
|
89
|
+
writeJSON(target, cfg);
|
|
72
90
|
},
|
|
73
|
-
remove() {
|
|
74
|
-
|
|
75
|
-
|
|
91
|
+
remove({ scope = "user", cwd = process.cwd() } = {}) {
|
|
92
|
+
const target = file(scope, cwd);
|
|
93
|
+
if (!fs.existsSync(target)) return false;
|
|
94
|
+
const cfg = readJSON(target);
|
|
76
95
|
if (!cfg[key]?.[SERVER_NAME]) return false;
|
|
77
96
|
delete cfg[key][SERVER_NAME];
|
|
78
|
-
writeJSON(
|
|
97
|
+
writeJSON(target, cfg);
|
|
79
98
|
return true;
|
|
80
99
|
},
|
|
81
100
|
};
|
|
@@ -83,6 +102,22 @@ function jsonClient({ id, name, file, detect, key = "mcpServers", entry }) {
|
|
|
83
102
|
|
|
84
103
|
const exists = (...p) => fs.existsSync(path.join(...p));
|
|
85
104
|
|
|
105
|
+
/** An executable called `cmd` on PATH (checked on disk, nothing is run). */
|
|
106
|
+
export function onPath(cmd) {
|
|
107
|
+
const exts = process.platform === "win32" ? (process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";") : [""];
|
|
108
|
+
return (process.env.PATH || "").split(path.delimiter).filter(Boolean)
|
|
109
|
+
.some((dir) => exts.some((ext) => fs.existsSync(path.join(dir, cmd + ext))));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A desktop app is installed: its app bundle or program folder exists, or
|
|
113
|
+
* its command is on PATH. A leftover settings folder does not count. */
|
|
114
|
+
function appInstalled({ mac, win, linux = [], cmd }) {
|
|
115
|
+
if (cmd && onPath(cmd)) return true;
|
|
116
|
+
if (process.platform === "darwin") return ["/Applications", path.join(home(), "Applications")].some((d) => exists(d, `${mac}.app`));
|
|
117
|
+
if (process.platform === "win32") return exists(process.env.LOCALAPPDATA || path.join(home(), "AppData", "Local"), "Programs", win);
|
|
118
|
+
return linux.some((p) => fs.existsSync(p));
|
|
119
|
+
}
|
|
120
|
+
|
|
86
121
|
// --- Codex (TOML) ------------------------------------------------------------------
|
|
87
122
|
|
|
88
123
|
const CODEX_HEADER = `[mcp_servers.${SERVER_NAME}]`;
|
|
@@ -107,66 +142,105 @@ const tomlString = (s) => JSON.stringify(s); // JSON strings are valid TOML basi
|
|
|
107
142
|
const codex = {
|
|
108
143
|
id: "codex",
|
|
109
144
|
name: "Codex",
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
145
|
+
aliases: [],
|
|
146
|
+
supportsScope: () => true,
|
|
147
|
+
file: (scope = "user", cwd = process.cwd()) => {
|
|
148
|
+
return scope === "project"
|
|
149
|
+
? path.join(cwd, ".codex", "config.toml")
|
|
150
|
+
: path.join(process.env.CODEX_HOME || path.join(home(), ".codex"), "config.toml");
|
|
151
|
+
},
|
|
152
|
+
detect: (opts = {}) => {
|
|
153
|
+
const scope = typeof opts === "string" ? opts : (opts?.scope || "user");
|
|
154
|
+
const cwd = opts?.cwd || process.cwd();
|
|
155
|
+
if (scope === "project") {
|
|
156
|
+
return exists(cwd, ".codex") || exists(cwd, "AGENTS.md") || exists(process.env.CODEX_HOME || path.join(home(), ".codex"));
|
|
157
|
+
}
|
|
158
|
+
return exists(process.env.CODEX_HOME || path.join(home(), ".codex"));
|
|
159
|
+
},
|
|
160
|
+
rules: (scope = "user", cwd = process.cwd()) => {
|
|
161
|
+
return scope === "project"
|
|
162
|
+
? path.join(cwd, "AGENTS.md")
|
|
163
|
+
: path.join(process.env.CODEX_HOME || path.join(home(), ".codex"), "AGENTS.md");
|
|
164
|
+
},
|
|
165
|
+
add({ mcpUrl, apiKey }, { scope = "user", cwd = process.cwd() } = {}) {
|
|
166
|
+
const target = codex.file(scope, cwd);
|
|
167
|
+
const current = fs.existsSync(target) ? stripCodexServer(fs.readFileSync(target, "utf8")) : "";
|
|
115
168
|
const block = [
|
|
116
169
|
CODEX_HEADER,
|
|
117
170
|
`url = ${tomlString(mcpUrl)}`,
|
|
118
171
|
`http_headers = { Authorization = ${tomlString(`Bearer ${apiKey}`)} }`,
|
|
119
172
|
].join("\n");
|
|
120
|
-
writeFileSafely(
|
|
173
|
+
writeFileSafely(target, (current ? current + "\n\n" : "") + block + "\n");
|
|
121
174
|
},
|
|
122
|
-
remove() {
|
|
123
|
-
const
|
|
124
|
-
if (!fs.existsSync(
|
|
125
|
-
const text = fs.readFileSync(
|
|
175
|
+
remove({ scope = "user", cwd = process.cwd() } = {}) {
|
|
176
|
+
const target = codex.file(scope, cwd);
|
|
177
|
+
if (!fs.existsSync(target)) return false;
|
|
178
|
+
const text = fs.readFileSync(target, "utf8");
|
|
126
179
|
if (!text.includes(CODEX_HEADER)) return false;
|
|
127
|
-
writeFileSafely(
|
|
180
|
+
writeFileSafely(target, stripCodexServer(text) + "\n");
|
|
128
181
|
return true;
|
|
129
182
|
},
|
|
130
183
|
};
|
|
131
184
|
|
|
132
185
|
// --- The catalogue --------------------------------------------------------------------
|
|
133
186
|
|
|
134
|
-
|
|
135
|
-
// and only edit the file when `claude` is not on PATH.
|
|
136
|
-
const claudeJSON = jsonClient({
|
|
137
|
-
id: "claude-code",
|
|
138
|
-
name: "Claude Code",
|
|
139
|
-
file: () => path.join(home(), ".claude.json"),
|
|
140
|
-
detect: () => exists(home(), ".claude.json") || exists(home(), ".claude"),
|
|
141
|
-
entry: ({ mcpUrl, apiKey }) => ({ type: "http", url: mcpUrl, headers: bearer(apiKey) }),
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
function claudeCLI(...args) {
|
|
187
|
+
export function runClaudeCLI(args, { cwd } = {}) {
|
|
145
188
|
try {
|
|
146
|
-
execFileSync("claude", args, { stdio: "ignore", timeout: 30_000, shell: process.platform === "win32" });
|
|
189
|
+
execFileSync("claude", args, { stdio: "ignore", timeout: 30_000, shell: process.platform === "win32", cwd });
|
|
147
190
|
return true;
|
|
148
191
|
} catch {
|
|
149
192
|
return false;
|
|
150
193
|
}
|
|
151
194
|
}
|
|
152
195
|
|
|
153
|
-
const hasClaudeCLI = () => !process.env.MEMRO_NO_CLAUDE_CLI &&
|
|
196
|
+
const hasClaudeCLI = () => !process.env.MEMRO_NO_CLAUDE_CLI && runClaudeCLI(["--version"]);
|
|
154
197
|
|
|
155
198
|
const claudeCode = {
|
|
156
|
-
|
|
157
|
-
|
|
199
|
+
id: "claude-code",
|
|
200
|
+
name: "Claude Code",
|
|
201
|
+
aliases: ["claude", "claudecode"],
|
|
202
|
+
supportsScope: (scope) => scope === "user" || scope === "project",
|
|
203
|
+
// Project-scoped servers live in the project's .mcp.json (what
|
|
204
|
+
// `claude mcp add --scope project` writes); user scope in ~/.claude.json.
|
|
205
|
+
file: (scope = "user", cwd = process.cwd()) => {
|
|
206
|
+
return scope === "project" ? path.join(cwd, ".mcp.json") : path.join(home(), ".claude.json");
|
|
207
|
+
},
|
|
208
|
+
detect: (opts = {}) => {
|
|
209
|
+
const scope = typeof opts === "string" ? opts : (opts?.scope || "user");
|
|
210
|
+
const cwd = opts?.cwd || process.cwd();
|
|
211
|
+
if (scope === "project") {
|
|
212
|
+
return exists(cwd, ".mcp.json") || exists(cwd, ".claude") || exists(cwd, "CLAUDE.md") || hasClaudeCLI() || exists(home(), ".claude.json") || exists(home(), ".claude");
|
|
213
|
+
}
|
|
214
|
+
return exists(home(), ".claude.json") || exists(home(), ".claude") || hasClaudeCLI();
|
|
215
|
+
},
|
|
216
|
+
rules: (scope = "user", cwd = process.cwd()) => {
|
|
217
|
+
return scope === "project" ? path.join(cwd, "CLAUDE.md") : path.join(home(), ".claude", "CLAUDE.md");
|
|
218
|
+
},
|
|
219
|
+
add(conn, { scope = "user", cwd = process.cwd() } = {}) {
|
|
158
220
|
// On Windows the CLI runs through cmd.exe, which splits the header at its
|
|
159
221
|
// spaces, so edit the file there instead.
|
|
160
222
|
if (process.platform !== "win32" && hasClaudeCLI()) {
|
|
161
|
-
|
|
162
|
-
if (
|
|
163
|
-
"--header", `Authorization: Bearer ${conn.apiKey}`)) return;
|
|
223
|
+
runClaudeCLI(["mcp", "remove", "--scope", scope, SERVER_NAME], { cwd });
|
|
224
|
+
if (runClaudeCLI(["mcp", "add", "--transport", "http", "--scope", scope, SERVER_NAME, conn.mcpUrl,
|
|
225
|
+
"--header", `Authorization: Bearer ${conn.apiKey}`], { cwd })) return;
|
|
164
226
|
}
|
|
165
|
-
|
|
227
|
+
const target = claudeCode.file(scope, cwd);
|
|
228
|
+
const cfg = readJSON(target);
|
|
229
|
+
cfg.mcpServers = { ...(cfg.mcpServers || {}), [SERVER_NAME]: { type: "http", url: conn.mcpUrl, headers: bearer(conn.apiKey) } };
|
|
230
|
+
writeJSON(target, cfg);
|
|
166
231
|
},
|
|
167
|
-
remove() {
|
|
168
|
-
if (hasClaudeCLI())
|
|
169
|
-
|
|
232
|
+
remove({ scope = "user", cwd = process.cwd() } = {}) {
|
|
233
|
+
if (hasClaudeCLI()) {
|
|
234
|
+
const ok = runClaudeCLI(["mcp", "remove", "--scope", scope, SERVER_NAME], { cwd });
|
|
235
|
+
if (ok) return true;
|
|
236
|
+
}
|
|
237
|
+
const target = claudeCode.file(scope, cwd);
|
|
238
|
+
if (!fs.existsSync(target)) return false;
|
|
239
|
+
const cfg = readJSON(target);
|
|
240
|
+
if (!cfg.mcpServers?.[SERVER_NAME]) return false;
|
|
241
|
+
delete cfg.mcpServers[SERVER_NAME];
|
|
242
|
+
writeJSON(target, cfg);
|
|
243
|
+
return true;
|
|
170
244
|
},
|
|
171
245
|
};
|
|
172
246
|
|
|
@@ -176,52 +250,71 @@ export const CLIENTS = [
|
|
|
176
250
|
jsonClient({
|
|
177
251
|
id: "cursor",
|
|
178
252
|
name: "Cursor",
|
|
179
|
-
|
|
180
|
-
|
|
253
|
+
aliases: [],
|
|
254
|
+
file: (scope, cwd) => (scope === "project" ? path.join(cwd, ".cursor", "mcp.json") : path.join(home(), ".cursor", "mcp.json")),
|
|
255
|
+
detect: (scope, cwd) => (scope === "project" ? exists(cwd, ".cursor") || exists(home(), ".cursor") : exists(home(), ".cursor")),
|
|
181
256
|
entry: ({ mcpUrl, apiKey }) => ({ url: mcpUrl, headers: bearer(apiKey) }),
|
|
182
257
|
}),
|
|
183
258
|
jsonClient({
|
|
184
259
|
id: "antigravity",
|
|
185
260
|
name: "Antigravity",
|
|
186
|
-
|
|
187
|
-
|
|
261
|
+
aliases: ["agy"],
|
|
262
|
+
file: (scope, cwd) => (scope === "project" ? path.join(cwd, ".gemini", "config", "mcp_config.json") : path.join(home(), ".gemini", "config", "mcp_config.json")),
|
|
263
|
+
detect: (scope, cwd) => (scope === "project"
|
|
264
|
+
? exists(cwd, ".gemini") || exists(cwd, ".agents") || exists(home(), ".gemini", "config") || exists(home(), ".gemini", "antigravity-ide") || exists(home(), ".gemini", "antigravity")
|
|
265
|
+
: exists(home(), ".gemini", "config") || exists(home(), ".gemini", "antigravity-ide") || exists(home(), ".gemini", "antigravity")),
|
|
188
266
|
entry: ({ mcpUrl, apiKey }) => ({ serverUrl: mcpUrl, headers: bearer(apiKey) }),
|
|
267
|
+
rules: (scope, cwd) => (scope === "project" ? path.join(cwd, ".agents", "rules", "memro.md") : path.join(home(), ".gemini", "config", "rules", "memro.md")),
|
|
189
268
|
}),
|
|
190
269
|
jsonClient({
|
|
191
270
|
id: "opencode",
|
|
192
271
|
name: "OpenCode",
|
|
193
|
-
|
|
194
|
-
|
|
272
|
+
aliases: [],
|
|
273
|
+
file: (scope, cwd) => (scope === "project" ? path.join(cwd, "opencode.json") : path.join(process.env.XDG_CONFIG_HOME || path.join(home(), ".config"), "opencode", "opencode.json")),
|
|
274
|
+
detect: (scope, cwd) => (scope === "project" ? exists(cwd, "opencode.json") || exists(process.env.XDG_CONFIG_HOME || path.join(home(), ".config"), "opencode") : exists(process.env.XDG_CONFIG_HOME || path.join(home(), ".config"), "opencode")),
|
|
195
275
|
key: "mcp",
|
|
196
276
|
entry: ({ mcpUrl, apiKey }) => ({ type: "remote", url: mcpUrl, enabled: true, headers: bearer(apiKey) }),
|
|
277
|
+
rules: (scope, cwd) => (scope === "project" ? path.join(cwd, "AGENTS.md") : path.join(process.env.XDG_CONFIG_HOME || path.join(home(), ".config"), "opencode", "AGENTS.md")),
|
|
197
278
|
}),
|
|
198
279
|
jsonClient({
|
|
199
280
|
id: "gemini-cli",
|
|
200
281
|
name: "Gemini CLI",
|
|
201
|
-
|
|
202
|
-
|
|
282
|
+
aliases: ["gemini"],
|
|
283
|
+
file: (scope, cwd) => (scope === "project" ? path.join(cwd, ".gemini", "settings.json") : path.join(home(), ".gemini", "settings.json")),
|
|
284
|
+
detect: (scope, cwd) => (scope === "project" ? exists(cwd, ".gemini") || exists(home(), ".gemini", "settings.json") : exists(home(), ".gemini", "settings.json")),
|
|
203
285
|
entry: ({ mcpUrl, apiKey }) => ({ httpUrl: mcpUrl, headers: bearer(apiKey) }),
|
|
286
|
+
rules: (scope, cwd) => (scope === "project" ? path.join(cwd, "GEMINI.md") : path.join(home(), ".gemini", "GEMINI.md")),
|
|
204
287
|
}),
|
|
205
288
|
jsonClient({
|
|
206
289
|
id: "vscode",
|
|
207
290
|
name: "VS Code",
|
|
208
|
-
|
|
209
|
-
|
|
291
|
+
aliases: ["code"],
|
|
292
|
+
file: (scope, cwd) => (scope === "project" ? path.join(cwd, ".vscode", "mcp.json") : appSupport("Code", "User", "mcp.json")),
|
|
293
|
+
detect: (scope, cwd) => (scope === "project" ? exists(cwd, ".vscode") || fs.existsSync(appSupport("Code", "User")) : fs.existsSync(appSupport("Code", "User"))),
|
|
210
294
|
key: "servers",
|
|
211
295
|
entry: ({ mcpUrl, apiKey }) => ({ type: "http", url: mcpUrl, headers: bearer(apiKey) }),
|
|
212
296
|
}),
|
|
213
297
|
jsonClient({
|
|
214
298
|
id: "windsurf",
|
|
215
299
|
name: "Windsurf",
|
|
300
|
+
aliases: [],
|
|
301
|
+
// Windsurf reads MCP servers from one user-wide file; it has no
|
|
302
|
+
// per-project MCP config.
|
|
303
|
+
supportsScope: (scope) => scope === "user",
|
|
216
304
|
file: () => path.join(home(), ".codeium", "windsurf", "mcp_config.json"),
|
|
217
|
-
|
|
305
|
+
// ~/.codeium/windsurf outlives an uninstall, so it is not proof.
|
|
306
|
+
detect: () => appInstalled({ mac: "Windsurf", win: "Windsurf", cmd: "windsurf", linux: ["/usr/share/windsurf", "/opt/Windsurf", "/opt/windsurf"] }),
|
|
218
307
|
entry: ({ mcpUrl, apiKey }) => ({ serverUrl: mcpUrl, headers: bearer(apiKey) }),
|
|
308
|
+
rules: () => path.join(home(), ".codeium", "windsurf", "memories", "global_rules.md"),
|
|
219
309
|
}),
|
|
220
310
|
jsonClient({
|
|
221
311
|
id: "claude-desktop",
|
|
222
312
|
name: "Claude Desktop",
|
|
313
|
+
aliases: ["desktop"],
|
|
314
|
+
supportsScope: (scope) => scope === "user",
|
|
223
315
|
file: () => appSupport("Claude", "claude_desktop_config.json"),
|
|
224
316
|
detect: () => fs.existsSync(appSupport("Claude")),
|
|
317
|
+
rules: () => null,
|
|
225
318
|
// Claude Desktop only launches local servers; mcp-remote bridges to /mcp.
|
|
226
319
|
entry: ({ mcpUrl, apiKey }) => ({
|
|
227
320
|
command: "npx",
|
|
@@ -232,4 +325,8 @@ export const CLIENTS = [
|
|
|
232
325
|
}),
|
|
233
326
|
];
|
|
234
327
|
|
|
235
|
-
export
|
|
328
|
+
export function findClient(id) {
|
|
329
|
+
if (!id) return undefined;
|
|
330
|
+
const norm = id.toLowerCase().trim();
|
|
331
|
+
return CLIENTS.find((c) => c.id === norm || c.aliases?.includes(norm));
|
|
332
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// The "use Memro automatically" note `memro connect` adds to each tool's
|
|
2
|
+
// global instructions file, between markers so it can be updated in place and
|
|
3
|
+
// removed by `memro disconnect`. The MCP server sends the same guidance to
|
|
4
|
+
// every client on connect (mcp/src/memro_mcp/server.py INSTRUCTIONS); this
|
|
5
|
+
// covers tools that ignore server instructions. Keep the two in step, and
|
|
6
|
+
// keep this short: Windsurf caps global rules at 6,000 characters.
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
export const START = "<!-- memro:start -->";
|
|
12
|
+
export const END = "<!-- memro:end -->";
|
|
13
|
+
|
|
14
|
+
export const BLOCK = `${START}
|
|
15
|
+
## Memro (long-term memory)
|
|
16
|
+
|
|
17
|
+
Memro is my long-term memory across tools and sessions (MCP server \`memro\`). Use it without being asked:
|
|
18
|
+
- At the start of a task, call \`recall\` with a short query about it and use what comes back.
|
|
19
|
+
- When I state a preference, make a decision, correct you, or you learn a lasting fact about me or a project, call \`remember\` with one short, self-contained sentence.
|
|
20
|
+
- Before answering about earlier work ("what did we decide about X?"), \`recall\` first.
|
|
21
|
+
|
|
22
|
+
Never store secrets (keys, passwords, tokens), one-off chatter, or anything I ask you not to keep. Don't announce routine saves.
|
|
23
|
+
${END}`;
|
|
24
|
+
|
|
25
|
+
export const PROJECT_BLOCK = `${START}
|
|
26
|
+
## Memro — long-term memory
|
|
27
|
+
|
|
28
|
+
- At the start of a task, recall relevant context from Memro.
|
|
29
|
+
- Remember durable decisions, preferences, and architectural changes.
|
|
30
|
+
- Never store secrets, API keys, passwords, or sensitive client data.
|
|
31
|
+
- Follow this repository's privacy and memory-sharing boundaries.
|
|
32
|
+
${END}`;
|
|
33
|
+
|
|
34
|
+
const globalBlockPattern = new RegExp(`\\n*${START}[\\s\\S]*?${END}\\n*`, "g");
|
|
35
|
+
|
|
36
|
+
/** Text without the Memro block (removes all instances and cleans up orphan markers). */
|
|
37
|
+
export function removeBlock(text) {
|
|
38
|
+
if (!text.includes(START) && !text.includes(END)) return text;
|
|
39
|
+
let out = text.replace(globalBlockPattern, "\n\n");
|
|
40
|
+
// Clean up any orphan markers if one was deleted accidentally
|
|
41
|
+
if (out.includes(START)) {
|
|
42
|
+
out = out.replace(new RegExp(`\\n*${START}[^\\n]*\\n*`, "g"), "\n\n");
|
|
43
|
+
}
|
|
44
|
+
if (out.includes(END)) {
|
|
45
|
+
out = out.replace(new RegExp(`\\n*${END}\\n*`, "g"), "\n\n");
|
|
46
|
+
}
|
|
47
|
+
out = out.replace(/^\n+/, "").replace(/\n{3,}/g, "\n\n");
|
|
48
|
+
return out.trim() ? out.replace(/\s+$/, "") + "\n" : "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Text with the Memro block added, or replaced if it is already there. */
|
|
52
|
+
export function upsertBlock(text, block = BLOCK) {
|
|
53
|
+
const cleaned = removeBlock(text).replace(/\s+$/, "");
|
|
54
|
+
return (cleaned ? cleaned + "\n\n" : "") + block + "\n";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Atomically replace a text file, keeping a one-time backup of the original
|
|
58
|
+
* and the file's existing permissions. */
|
|
59
|
+
function writeText(file, text) {
|
|
60
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
61
|
+
const existed = fs.existsSync(file);
|
|
62
|
+
if (existed && !fs.existsSync(`${file}.memro-backup`)) fs.copyFileSync(file, `${file}.memro-backup`);
|
|
63
|
+
const tmp = `${file}.memro-tmp-${process.pid}`;
|
|
64
|
+
try {
|
|
65
|
+
fs.writeFileSync(tmp, text, existed ? { mode: fs.statSync(file).mode & 0o777 } : {});
|
|
66
|
+
fs.renameSync(tmp, file);
|
|
67
|
+
} finally {
|
|
68
|
+
fs.rmSync(tmp, { force: true });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Whether text already tells the agent to use Memro, outside our block
|
|
73
|
+
* (e.g. guidance the user wrote themselves). */
|
|
74
|
+
export function hasOwnMemroGuidance(text) {
|
|
75
|
+
const rest = removeBlock(text);
|
|
76
|
+
return /memro/i.test(rest) && /\brecall\b/i.test(rest);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Add or refresh the block in a tool's instructions file. If the file already
|
|
81
|
+
* has its own Memro guidance, leave that in charge: add nothing, and drop an
|
|
82
|
+
* older block of ours so the agent never gets the rules twice. Returns
|
|
83
|
+
* "added", "updated", "unchanged" or "kept-own".
|
|
84
|
+
*/
|
|
85
|
+
export function installInstructions(file, { scope = "user", block } = {}) {
|
|
86
|
+
const targetBlock = block || (scope === "project" ? PROJECT_BLOCK : BLOCK);
|
|
87
|
+
const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
88
|
+
if (hasOwnMemroGuidance(before)) {
|
|
89
|
+
const after = removeBlock(before);
|
|
90
|
+
if (after !== before) writeText(file, after);
|
|
91
|
+
return "kept-own";
|
|
92
|
+
}
|
|
93
|
+
const after = upsertBlock(before, targetBlock);
|
|
94
|
+
if (after === before) return "unchanged";
|
|
95
|
+
writeText(file, after);
|
|
96
|
+
return before.includes(START) ? "updated" : "added";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Remove the block; returns whether there was one. Deletes the file if
|
|
100
|
+
* Memro's block was all it contained. */
|
|
101
|
+
export function uninstallInstructions(file) {
|
|
102
|
+
if (!fs.existsSync(file)) return false;
|
|
103
|
+
const before = fs.readFileSync(file, "utf8");
|
|
104
|
+
const after = removeBlock(before);
|
|
105
|
+
if (after === before) return false;
|
|
106
|
+
if (after === "") fs.rmSync(file);
|
|
107
|
+
else writeText(file, after);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
package/src/picker.js
CHANGED
|
@@ -1,28 +1,30 @@
|
|
|
1
|
-
//
|
|
2
|
-
// toggles, "a" toggles all, Enter confirms, Esc/q/Ctrl-C cancels. No
|
|
1
|
+
// Small prompts for the terminal: multi-select, single-select and text. No
|
|
3
2
|
// dependencies; the state logic is pure so it can be tested without a TTY.
|
|
3
|
+
//
|
|
4
|
+
// Keys: ↑/↓ (or j/k) move, space toggles, "a" toggles all, Enter confirms,
|
|
5
|
+
// Esc/q/Ctrl-C cancels. While a prompt is open it is drawn as an active step
|
|
6
|
+
// (◆); once answered it collapses to its question and answer (◇), so the
|
|
7
|
+
// screen keeps a short record instead of every frame.
|
|
4
8
|
|
|
5
9
|
import readline from "node:readline";
|
|
6
10
|
|
|
11
|
+
import { c, S, bar, liveFrame } from "./ui.js";
|
|
12
|
+
|
|
7
13
|
const ESC = "\x1b[";
|
|
8
|
-
const c = {
|
|
9
|
-
dim: (s) => `${ESC}2m${s}${ESC}22m`,
|
|
10
|
-
bold: (s) => `${ESC}1m${s}${ESC}22m`,
|
|
11
|
-
accent: (s) => `${ESC}38;5;141m${s}${ESC}39m`,
|
|
12
|
-
green: (s) => `${ESC}32m${s}${ESC}39m`,
|
|
13
|
-
};
|
|
14
14
|
|
|
15
15
|
/** Initial state: items are { label, hint?, checked }. */
|
|
16
16
|
export function initialState(items) {
|
|
17
17
|
return { items: items.map((i) => ({ ...i })), cursor: 0, done: false, cancelled: false };
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
const isCancel = (key, name) => (key.ctrl && name === "c") || name === "escape" || name === "q";
|
|
21
|
+
|
|
20
22
|
/** Apply one key ({ name, ctrl, sequence }) to the state. */
|
|
21
23
|
export function reduce(state, key) {
|
|
22
24
|
const s = { ...state, items: state.items.map((i) => ({ ...i })) };
|
|
23
25
|
const n = s.items.length;
|
|
24
26
|
const name = key.name || key.sequence;
|
|
25
|
-
if ((key
|
|
27
|
+
if (isCancel(key, name)) {
|
|
26
28
|
s.cancelled = true;
|
|
27
29
|
} else if (name === "return" || name === "enter") {
|
|
28
30
|
s.done = true;
|
|
@@ -32,53 +34,60 @@ export function reduce(state, key) {
|
|
|
32
34
|
s.cursor = (s.cursor + 1) % n;
|
|
33
35
|
} else if (name === "space" || name === " ") {
|
|
34
36
|
s.items[s.cursor].checked = !s.items[s.cursor].checked;
|
|
35
|
-
} else if (name === "a") {
|
|
36
|
-
const
|
|
37
|
-
|
|
37
|
+
} else if (name === "a") { // all tools; options below the list keep their state
|
|
38
|
+
const tools = s.items.filter((i) => !i.option);
|
|
39
|
+
const all = tools.every((i) => i.checked);
|
|
40
|
+
tools.forEach((i) => { i.checked = !all; });
|
|
38
41
|
}
|
|
39
42
|
return s;
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
const header = (title, keys) => [`${c.accent(S.active)} ${c.bold(title)}`, `${bar()} ${c.dim(keys)}`, bar()];
|
|
46
|
+
|
|
42
47
|
/** The lines to draw for a state. */
|
|
43
48
|
export function render(state, title) {
|
|
44
|
-
const lines =
|
|
49
|
+
const lines = header(title, "↑/↓ move · space select · a all · enter confirm · esc cancel");
|
|
45
50
|
state.items.forEach((item, i) => {
|
|
51
|
+
if (item.option && !state.items[i - 1]?.option) lines.push(bar()); // options sit below the list
|
|
46
52
|
const here = i === state.cursor;
|
|
47
|
-
const box = item.checked ? c.green(
|
|
48
|
-
const label = here ? c.accent(item.label) : item.label;
|
|
53
|
+
const box = item.checked ? c.green(S.checkOn) : c.dim(S.checkOff);
|
|
54
|
+
const label = here ? c.accent(item.label) : item.checked ? item.label : c.dim(item.label);
|
|
49
55
|
const hint = item.hint ? " " + c.dim(item.hint) : "";
|
|
50
|
-
lines.push(
|
|
56
|
+
lines.push(`${bar()} ${here ? c.accent(S.cursor) : " "} ${box} ${label}${hint}`);
|
|
51
57
|
});
|
|
52
|
-
const count = state.items.filter((i) => i.checked).length;
|
|
53
|
-
lines.push(
|
|
58
|
+
const count = state.items.filter((i) => i.checked && !i.option).length;
|
|
59
|
+
lines.push(bar(), `${c.gray(S.end)} ${c.dim(`${count} selected`)}`);
|
|
54
60
|
return lines;
|
|
55
61
|
}
|
|
56
62
|
|
|
63
|
+
/** One-line answer for a finished multi-select. */
|
|
64
|
+
export function summary(state) {
|
|
65
|
+
const tools = state.items.filter((i) => i.checked && !i.option).map((i) => i.label);
|
|
66
|
+
const options = state.items.filter((i) => i.checked && i.option).map((i) => i.summary || i.label);
|
|
67
|
+
return [tools.join(", ") || "none", ...options].join(" · ");
|
|
68
|
+
}
|
|
69
|
+
|
|
57
70
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
71
|
+
* Run a prompt: draw it, apply keys, and on Enter/cancel replace the frame
|
|
72
|
+
* with the collapsed step. Resolves with onDone(state) or null.
|
|
60
73
|
*/
|
|
61
|
-
|
|
74
|
+
function run({ state, reduceFn, renderFn, title, answer, onDone, input, output }) {
|
|
62
75
|
return new Promise((resolve) => {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
const lines = render(state, title);
|
|
67
|
-
if (drawn) output.write(`${ESC}${drawn}A`); // back to the top of the last frame
|
|
68
|
-
output.write(lines.map((l) => `${ESC}2K${l}`).join("\n") + "\n");
|
|
69
|
-
drawn = lines.length;
|
|
70
|
-
};
|
|
71
|
-
const finish = (value) => {
|
|
76
|
+
const frame = liveFrame(output);
|
|
77
|
+
const draw = () => frame.draw(renderFn(state, title));
|
|
78
|
+
const finish = (value, collapsed) => {
|
|
72
79
|
input.off("keypress", onKey);
|
|
80
|
+
output.off?.("resize", draw);
|
|
73
81
|
if (input.isTTY) input.setRawMode(false);
|
|
74
82
|
input.pause();
|
|
83
|
+
frame.draw(collapsed);
|
|
75
84
|
output.write(`${ESC}?25h`); // show the cursor again
|
|
76
85
|
resolve(value);
|
|
77
86
|
};
|
|
78
87
|
const onKey = (_str, key = {}) => {
|
|
79
|
-
state =
|
|
80
|
-
if (state.cancelled) return finish(null);
|
|
81
|
-
if (state.done) return finish(state.
|
|
88
|
+
state = reduceFn(state, key);
|
|
89
|
+
if (state.cancelled) return finish(null, [`${c.red(S.cancel)} ${title}`, `${bar()} ${c.dim("Cancelled")}`]);
|
|
90
|
+
if (state.done) return finish(onDone(state), [`${c.green(S.done)} ${title}`, `${bar()} ${c.dim(answer(state))}`, bar()]);
|
|
82
91
|
draw();
|
|
83
92
|
};
|
|
84
93
|
readline.emitKeypressEvents(input);
|
|
@@ -86,6 +95,79 @@ export function pick(items, { title, input = process.stdin, output = process.std
|
|
|
86
95
|
input.resume();
|
|
87
96
|
output.write(`${ESC}?25l`); // hide the cursor while choosing
|
|
88
97
|
input.on("keypress", onKey);
|
|
98
|
+
output.on?.("resize", draw);
|
|
89
99
|
draw();
|
|
90
100
|
});
|
|
91
101
|
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Show the picker and resolve with the selected items, or null if the user
|
|
105
|
+
* cancelled. Requires an interactive terminal on `input`.
|
|
106
|
+
*/
|
|
107
|
+
export function pick(items, { title, input = process.stdin, output = process.stdout } = {}) {
|
|
108
|
+
return run({
|
|
109
|
+
state: initialState(items), reduceFn: reduce, renderFn: render, title, input, output,
|
|
110
|
+
answer: summary,
|
|
111
|
+
onDone: (s) => s.items.filter((i) => i.checked),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Initial state for single-select: items are { label, hint?, value }. */
|
|
116
|
+
export function initialSingleState(items, defaultIndex = 0) {
|
|
117
|
+
return { items: items.map((i) => ({ ...i })), cursor: defaultIndex, done: false, cancelled: false };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Apply key to single-select state. */
|
|
121
|
+
export function reduceSingle(state, key) {
|
|
122
|
+
const s = { ...state, items: state.items.map((i) => ({ ...i })) };
|
|
123
|
+
const n = s.items.length;
|
|
124
|
+
const name = key.name || key.sequence;
|
|
125
|
+
if (isCancel(key, name)) {
|
|
126
|
+
s.cancelled = true;
|
|
127
|
+
} else if (name === "return" || name === "enter") {
|
|
128
|
+
s.done = true;
|
|
129
|
+
} else if (name === "up" || name === "k") {
|
|
130
|
+
s.cursor = (s.cursor - 1 + n) % n;
|
|
131
|
+
} else if (name === "down" || name === "j" || name === "tab") {
|
|
132
|
+
s.cursor = (s.cursor + 1) % n;
|
|
133
|
+
}
|
|
134
|
+
return s;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Render lines for single-select. The hint shows for the highlighted choice. */
|
|
138
|
+
export function renderSingle(state, title) {
|
|
139
|
+
const lines = header(title, "↑/↓ move · enter select · esc cancel");
|
|
140
|
+
state.items.forEach((item, i) => {
|
|
141
|
+
const here = i === state.cursor;
|
|
142
|
+
const dot = here ? c.green(S.radioOn) : c.dim(S.radioOff);
|
|
143
|
+
const label = here ? c.accent(item.label) : c.dim(item.label);
|
|
144
|
+
lines.push(`${bar()} ${here ? c.accent(S.cursor) : " "} ${dot} ${label}`);
|
|
145
|
+
if (here && item.hint) lines.push(`${bar()} ${c.dim(item.hint)}`);
|
|
146
|
+
});
|
|
147
|
+
lines.push(c.gray(S.end));
|
|
148
|
+
return lines;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Show a single-select picker and resolve with the chosen item, or null if cancelled.
|
|
153
|
+
*/
|
|
154
|
+
export function pickOne(items, { title, defaultIndex = 0, input = process.stdin, output = process.stdout } = {}) {
|
|
155
|
+
return run({
|
|
156
|
+
state: initialSingleState(items, defaultIndex), reduceFn: reduceSingle, renderFn: renderSingle, title, input, output,
|
|
157
|
+
answer: (s) => s.items[s.cursor].summary || s.items[s.cursor].label,
|
|
158
|
+
onDone: (s) => s.items[s.cursor],
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Ask a simple text prompt.
|
|
164
|
+
*/
|
|
165
|
+
export function promptText(query, { input = process.stdin, output = process.stdout } = {}) {
|
|
166
|
+
return new Promise((resolve) => {
|
|
167
|
+
const rl = readline.createInterface({ input, output });
|
|
168
|
+
rl.question(query, (ans) => {
|
|
169
|
+
rl.close();
|
|
170
|
+
resolve(ans.trim());
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
}
|