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/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({ id, name, file, detect, key = "mcpServers", entry }) {
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
- file,
67
- detect,
68
- add(conn) {
69
- const cfg = readJSON(file());
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(file(), cfg);
89
+ writeJSON(target, cfg);
72
90
  },
73
- remove() {
74
- if (!fs.existsSync(file())) return false;
75
- const cfg = readJSON(file());
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(file(), cfg);
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
- file: () => path.join(process.env.CODEX_HOME || path.join(home(), ".codex"), "config.toml"),
111
- detect: () => exists(process.env.CODEX_HOME || path.join(home(), ".codex")),
112
- add({ mcpUrl, apiKey }) {
113
- const file = codex.file();
114
- const current = fs.existsSync(file) ? stripCodexServer(fs.readFileSync(file, "utf8")) : "";
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(file, (current ? current + "\n\n" : "") + block + "\n");
173
+ writeFileSafely(target, (current ? current + "\n\n" : "") + block + "\n");
121
174
  },
122
- remove() {
123
- const file = codex.file();
124
- if (!fs.existsSync(file)) return false;
125
- const text = fs.readFileSync(file, "utf8");
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(file, stripCodexServer(text) + "\n");
180
+ writeFileSafely(target, stripCodexServer(text) + "\n");
128
181
  return true;
129
182
  },
130
183
  };
131
184
 
132
185
  // --- The catalogue --------------------------------------------------------------------
133
186
 
134
- // Claude Code rewrites ~/.claude.json while it runs, so prefer its own CLI
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 && claudeCLI("--version");
196
+ const hasClaudeCLI = () => !process.env.MEMRO_NO_CLAUDE_CLI && runClaudeCLI(["--version"]);
154
197
 
155
198
  const claudeCode = {
156
- ...claudeJSON,
157
- add(conn) {
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
- claudeCLI("mcp", "remove", "--scope", "user", SERVER_NAME);
162
- if (claudeCLI("mcp", "add", "--transport", "http", "--scope", "user", SERVER_NAME, conn.mcpUrl,
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
- claudeJSON.add(conn);
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()) return claudeCLI("mcp", "remove", "--scope", "user", SERVER_NAME);
169
- return claudeJSON.remove();
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
- file: () => path.join(home(), ".cursor", "mcp.json"),
180
- detect: () => exists(home(), ".cursor"),
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
- file: () => path.join(home(), ".gemini", "antigravity", "mcp_config.json"),
187
- detect: () => exists(home(), ".gemini", "antigravity"),
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
- file: () => path.join(process.env.XDG_CONFIG_HOME || path.join(home(), ".config"), "opencode", "opencode.json"),
194
- detect: () => exists(process.env.XDG_CONFIG_HOME || path.join(home(), ".config"), "opencode"),
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
- file: () => path.join(home(), ".gemini", "settings.json"),
202
- detect: () => exists(home(), ".gemini", "settings.json"),
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
- file: () => appSupport("Code", "User", "mcp.json"),
209
- detect: () => fs.existsSync(appSupport("Code", "User")),
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
- detect: () => exists(home(), ".codeium", "windsurf"),
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 const findClient = (id) => CLIENTS.find((c) => c.id === id);
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
- // A small multi-select for the terminal: arrow keys (or j/k) move, space
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.ctrl && name === "c") || name === "escape" || name === "q") {
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 all = s.items.every((i) => i.checked);
37
- s.items.forEach((i) => { i.checked = !all; });
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 = [` ${c.bold(title)}`, c.dim(" ↑/↓ move · space select · a all · enter confirm · esc cancel"), ""];
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("◉") : c.dim("○");
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(` ${here ? c.accent("›") : " "} ${box} ${label}${hint}`);
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("", c.dim(` ${count} selected`));
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
- * Show the picker and resolve with the selected items, or null if the user
59
- * cancelled. Requires an interactive terminal on `input`.
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
- export function pick(items, { title, input = process.stdin, output = process.stdout } = {}) {
74
+ function run({ state, reduceFn, renderFn, title, answer, onDone, input, output }) {
62
75
  return new Promise((resolve) => {
63
- let state = initialState(items);
64
- let drawn = 0;
65
- const draw = () => {
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 = reduce(state, key);
80
- if (state.cancelled) return finish(null);
81
- if (state.done) return finish(state.items.filter((i) => i.checked));
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
+ }