@synqa/cli 1.1.0 → 1.2.0

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/dist/paths.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { closeSync, constants, lstatSync, openSync, realpathSync, writeSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
2
3
  import { homedir } from "node:os";
3
- import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
4
4
  import { configDir } from "./config.js";
5
5
 
6
6
  export class ConfineError extends Error {}
@@ -62,47 +62,171 @@ export function fold(s ) {
62
62
  return s.normalize("NFKC").toLowerCase();
63
63
  }
64
64
 
65
- // Folders whose contents are credentials.
66
- const SENSITIVE_DIRS = new Set([".ssh", ".aws", ".gnupg"]);
65
+ // The protected tables. isSensitivePath (file tools) and commandTouchesSecrets (shell) are both
66
+ // built from these, so a name added here is protected everywhere at once.
67
+ // Folders whose contents are credentials, matched as any one path segment.
68
+ export const SENSITIVE_DIRS = [".ssh", ".aws", ".gnupg", ".kube", ".password-store", ".mozilla", ".azure", "keychains"];
69
+ // Runs of path segments that hold credentials, browser profiles or tokens, matched anywhere in the
70
+ // path (folded). Inside the home folder every dot entry and Library is protected anyway (see
71
+ // homeHidden); these runs also protect copies of such folders elsewhere.
72
+ export const SENSITIVE_RUNS = [
73
+ [".config", "synqa"], [".config", "gh"], [".config", "hub"], [".config", "gcloud"], [".config", "op"], [".config", "heroku"],
74
+ [".config", "google-chrome"], [".config", "chromium"], [".config", "bravesoftware"], [".config", "microsoft-edge"],
75
+ [".docker", "config.json"], [".local", "share", "keyrings"], [".cargo", "credentials.toml"], [".gem", "credentials"],
76
+ [".terraform.d", "credentials.tfrc.json"], [".m2", "settings.xml"], [".oci", "config"],
77
+ ["library", "cookies"], ["library", "safari"], ["library", "mail"], ["library", "messages"],
78
+ ["library", "application support", "google", "chrome"], ["library", "application support", "chromium"],
79
+ ["library", "application support", "bravesoftware"], ["library", "application support", "microsoft edge"],
80
+ ["library", "application support", "arc"], ["library", "application support", "firefox"],
81
+ ["library", "application support", "1password"], ["library", "group containers"],
82
+ ];
67
83
  // Exact file names that hold secrets (compared folded).
68
- const SENSITIVE_NAMES = new Set([
84
+ export const SENSITIVE_NAMES = [
69
85
  ".env", ".envrc", ".netrc", ".npmrc", ".pypirc", ".git-credentials", ".dev.vars", "keys.json", "credentials.json", ".yarnrc.yml", ".pgpass", ".my.cnf",
70
- ]);
86
+ "_netrc", ".boto", ".s3cfg", ".vault-token", ".terraformrc", "login data", "cookies", "cookies.binarycookies", "key4.db", "logins.json",
87
+ ".zsh_history", ".bash_history", ".python_history", ".node_repl_history", ".psql_history", ".mysql_history", ".lesshst",
88
+ ".yarnrc",
89
+ ];
90
+ // File extensions of private keys, certificates with keys, key stores and infrastructure state.
91
+ export const SENSITIVE_EXTENSIONS = [
92
+ "pem", "key", "p12", "p8", "pfx", "jks", "keystore", "ppk", "tfvars", "tfstate", "keychain", "keychain-db", "kdbx",
93
+ ];
94
+ // Name patterns, each with the looser form a shell command is checked with. One table for both.
95
+ // `word` rules guess from a word in the name, so they skip source code (src/auth/credentials.ts is
96
+ // code about credentials, not credentials).
97
+ export const SENSITIVE_PATTERNS = [
98
+ { name: /^\.env\.|\.env$/, shell: /\.env/ }, // .env.local, prod.env, config.env
99
+ { name: /\.tfstate(\.|$)/, shell: /\.tfstate/ }, // terraform.tfstate, terraform.tfstate.backup
100
+ { name: /service[-_]?account.*\.json$/, shell: /service[-_]?account/ },
101
+ // credential(s) or secret(s) as a word ("client_secret.json", "aws-secrets.yaml"; not "secretary.ts")
102
+ { name: /(^|[^a-z])(credential|secret)s?([^a-z]|$)/, shell: /credential|secret/, word: true },
103
+ // shell and REPL histories (.zsh_history, .python_history, history): the name ends in "history"
104
+ { name: /history$/, shell: /history/ }, // only a name that ends in "history": history.md and chat_history.ts never match
105
+ { name: /^id_[a-z0-9_]+$/, shell: /\bid_[a-z0-9]/ }, // id_rsa, id_ed25519; id_rsa.pub is not matched
106
+ ];
107
+ // Source files: a word rule never applies to these (named secret files still do).
108
+ export const SOURCE_EXTENSIONS = [
109
+ "ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "go", "rs", "rb", "java", "kt", "swift", "c", "cc", "cpp", "h", "cs", "php", "vue", "svelte",
110
+ ];
111
+ const SOURCE_RE = new RegExp(`\\.(${SOURCE_EXTENSIONS.join("|")})$`);
112
+ // Source files whose whole stem says secret (secrets.py, credentials.js, client_secret.ts) often
113
+ // hold real keys: the word rules let them through, so read_file asks first and grep and glob skip
114
+ // them. A longer name (secret_manager.go, credentials_form.ts) is ordinary code.
115
+ export const SECRET_STEMS = ["secret", "secrets", "credential", "credentials", "client_secret", "client_secrets"];
116
+
117
+ export function isSecretStemSource(abs ) {
118
+ const name = fold(abs.split(sep).join("/")).split("/").pop() ?? "";
119
+ if (!SOURCE_RE.test(name)) return false;
120
+ return SECRET_STEMS.includes(name.slice(0, name.lastIndexOf(".")));
121
+ }
122
+ // First segments of SENSITIVE_RUNS that are credential folders in their own right, so a shell
123
+ // command that names the folder (ls .docker, cat ~/.docker/*) asks too. .config and .local hold
124
+ // much else and are covered by the home rule.
125
+ const RUN_FOLDERS = [...new Set(SENSITIVE_RUNS.map((r) => r[0] ))].filter((f) => f.startsWith(".") && f !== ".config" && f !== ".local");
126
+ const NAME_SET = new Set(SENSITIVE_NAMES);
127
+ const DIR_SET = new Set(SENSITIVE_DIRS);
71
128
  const EXAMPLE_SUFFIX = /\.(example|sample|template|dist)$/;
129
+ const EXT_RE = new RegExp(`\\.(${SENSITIVE_EXTENSIONS.map((e) => e.replace(/[.-]/g, "\\$&")).join("|")})$`);
72
130
 
73
- // Files and folders that hold secrets: .env files, private keys, cloud, package manager and
74
- // container credentials, and this CLI's own config (which holds the API key). Names only, no file
75
- // is opened. Every segment is folded first (see fold).
76
- export function isSensitivePath(abs , cfg = configDir()) {
77
- const norm = fold(abs.split(sep).join("/"));
78
- const parts = norm.split("/").filter(Boolean);
79
- if (parts.some((d) => SENSITIVE_DIRS.has(d))) return true;
80
- for (let i = 0; i + 1 < parts.length; i++) {
81
- if (parts[i] === ".config" && parts[i + 1] === "synqa") return true;
82
- if (parts[i] === ".kube" && parts[i + 1] === "config") return true;
83
- if (parts[i] === ".docker" && parts[i + 1] === "config.json") return true;
131
+ function hasRun(parts , run ) {
132
+ for (let i = 0; i + run.length <= parts.length; i++) {
133
+ if (run.every((seg, j) => parts[i + j] === seg)) return true;
84
134
  }
85
- const c = fold(cfg.split(sep).join("/")).replace(/\/+$/, "");
135
+ return false;
136
+ }
137
+
138
+ // One slash form for every comparison. On win32 both separators count and the drive letter and
139
+ // every name are folded, so a Windows AppData folder in any letter case matches. The platform is
140
+ // a parameter so the Windows rules are tested on any machine.
141
+ const slashed = (p , platform = process.platform) =>
142
+ fold(platform === "win32" ? p.replace(/\\/g, "/") : p.split(sep).join("/")).replace(/\/+$/, "");
143
+
144
+ // The home folder, as given and with symlinks resolved, folded.
145
+ function homeForms(home , platform = process.platform) {
146
+ const out = [slashed(home, platform)];
147
+ try {
148
+ out.push(slashed(realpathSync(home), platform));
149
+ } catch {
150
+ // a home that does not exist is compared as given
151
+ }
152
+ return out.filter((h) => h !== "" && h !== "/");
153
+ }
154
+
155
+ // Inside the home folder, everything under a dot entry at its top (~/.zsh_history, ~/.config/**,
156
+ // ~/.cargo/**, any tool's credential store, known or not) and everything under
157
+ // ~/Library is protected. This is the structural rule: it does not depend on naming each store.
158
+ // A project's own dot files (~/proj/.github, ~/proj/.eslintrc) are below a normal folder and stay usable.
159
+ // On Windows the same rule covers AppData (browser profiles, tokens, credential stores) and the
160
+ // per-user registry hive NTUSER.DAT with its log and backup files.
161
+ export function homeHidden(abs , home = homedir(), platform = process.platform) {
162
+ const norm = slashed(abs, platform);
163
+ for (const h of homeForms(home, platform)) {
164
+ if (!norm.startsWith(h + "/")) continue;
165
+ const first = norm.slice(h.length + 1).split("/")[0] ?? "";
166
+ if (first.startsWith(".") || first === "library") return true;
167
+ if (platform === "win32" && (first === "appdata" || first.startsWith("ntuser.dat") || first === "ntuser.ini")) return true;
168
+ }
169
+ return false;
170
+ }
171
+
172
+ // True when `cwd` is the home folder itself.
173
+ export function isHomeDir(cwd , home = homedir(), platform = process.platform) {
174
+ let real = slashed(cwd, platform);
175
+ try {
176
+ real = slashed(realpathSync(cwd), platform);
177
+ } catch {
178
+ // compared as given
179
+ }
180
+ return homeForms(home, platform).includes(real) || homeForms(home, platform).includes(slashed(cwd, platform));
181
+ }
182
+
183
+ // Files and folders that hold secrets: the home folder's dot entries and Library (homeHidden),
184
+ // env files, private keys, cloud, package manager and container credentials, keychains, browser
185
+ // profiles, histories, and this CLI's own config (which holds the API key). Names only, no file is
186
+ // opened. Every segment is folded first (see fold). read_file, write_file and edit_file refuse
187
+ // these outright; grep and glob skip them.
188
+ export function isSensitivePath(abs , cfg = configDir(), home = homedir(), platform = process.platform) {
189
+ if (homeHidden(abs, home, platform)) return true;
190
+ const norm = slashed(abs, platform);
191
+ const parts = norm.split("/").filter(Boolean);
192
+ if (parts.some((d) => DIR_SET.has(d))) return true;
193
+ if (SENSITIVE_RUNS.some((run) => hasRun(parts, run))) return true;
194
+ const c = slashed(cfg, platform);
86
195
  if (c && (norm === c || norm.startsWith(c + "/"))) return true;
87
196
  const name = parts[parts.length - 1] ?? "";
88
197
  // Remotes can hold tokens: .git/config, .git/config.worktree, .git/worktrees/<name>/config, at any depth.
89
198
  if (parts.slice(0, -1).includes(".git") && (name === "config" || name.startsWith("config."))) return true;
90
- if (SENSITIVE_NAMES.has(name)) return true;
91
- if (name.startsWith(".env.") && !EXAMPLE_SUFFIX.test(name)) return true;
92
- if (/\.(pem|key|p12|pfx|tfvars|tfstate)$/.test(name)) return true;
93
- if (/^secrets\./.test(name) && !EXAMPLE_SUFFIX.test(name)) return true;
94
- return /^id_[a-z0-9_]+$/.test(name); // id_rsa, id_ed25519; id_rsa.pub is not matched
199
+ if (NAME_SET.has(name)) return true;
200
+ if (EXAMPLE_SUFFIX.test(name)) return false;
201
+ if (EXT_RE.test(name)) return true;
202
+ const source = SOURCE_RE.test(name);
203
+ return SENSITIVE_PATTERNS.some((x) => x.name.test(name) && !(x.word && source));
95
204
  }
96
205
 
97
- // Names a shell command can use to reach a secret, including globs that expand to them. Heuristic:
98
- // it makes an unattended (--yes) command ask when it looks at secrets, it does not make bash safe.
99
- const SECRET_WORDS = [
100
- /\.env/, /\.ssh/, /\.aws/, /\.gnupg/, /\.kube/, /\.docker/, /\.netrc/, /\.npmrc/, /\.pypirc/, /\.git-credentials/,
101
- /\.dev\.vars/, /keys\.json/, /credentials/, /\.tfvars/, /\bsecrets?\b/, /\.(pem|key|p12|pfx)\b/, /\bid_[a-z0-9]/,
102
- /\.config\/synqa/, /synqa\/config/, /(^|[\s/])\.[\w.-]*[*?[]/, /~\/?\./,
206
+ const escRe = (s ) => s.replace(/[.*+?^${}()|[\]\\/-]/g, "\\$&");
207
+
208
+ // Names a shell command can use to reach a secret, including globs that expand to them, built from
209
+ // the same tables as isSensitivePath. Heuristic: it makes an unattended (--yes) command ask when it
210
+ // looks at secrets, it does not make bash safe.
211
+ const SECRET_WORDS = [
212
+ ...SENSITIVE_DIRS.map((d) => new RegExp(escRe(d))),
213
+ ...SENSITIVE_RUNS.map((r) => new RegExp(r.map(escRe).join("/"))),
214
+ ...SENSITIVE_NAMES.map((n) => new RegExp(escRe(n))),
215
+ ...RUN_FOLDERS.map((f) => new RegExp(`${escRe(f)}\\b`)),
216
+ new RegExp(`\\.(${SENSITIVE_EXTENSIONS.map(escRe).join("|")})\\b`),
217
+ ...SENSITIVE_PATTERNS.map((x) => x.shell),
218
+ /\.git\/config/,
219
+ // Dumping the environment (where tokens live): printenv, env or export -p or set with no arguments.
220
+ /\bprintenv\b/, /(^|[;&|(]\s*)env\s*($|[;&|)>])/, /\bexport\s+-p\b/, /(^|[;&|(]\s*)(set|declare\s+-x)\s*($|[;&|)>])/,
221
+ /\.config\/synqa/, /synqa\/config/,
222
+ /(^|[\s/])\.[\w.-]*[*?[]/, // a dot glob such as .e* or .*
223
+ /~\/?\./, /\$\{?home\}?\/?\./, /~\/library/, /\$\{?home\}?\/library/, // the home folder's dot entries and Library
224
+ /\bsecurity\s+(find|dump|export)-/, /\.binarycookies/,
103
225
  ];
104
226
 
105
227
  export function commandTouchesSecrets(command , cfg = configDir()) {
228
+ // Escapes that can spell any name ($'\x2eenv', \056, printf '\x2e'): always ask.
229
+ if (/\$'|\\x[0-9a-f]{2}|\\[0-7]{3}|\\u[0-9a-f]{4}/i.test(command)) return true;
106
230
  // Quotes and backslashes are dropped first so .e""nv and .\env read as .env.
107
231
  const c = fold(command).replace(/["'\\`]/g, "");
108
232
  if (SECRET_WORDS.some((re) => re.test(c))) return true;
@@ -110,22 +234,17 @@ export function commandTouchesSecrets(command , cfg = configDir()
110
234
  return dir !== "" && c.includes(dir);
111
235
  }
112
236
 
113
- // A start folder this CLI refuses without --allow-home: the home folder or the filesystem root.
114
- // Returns the reason, or undefined when the folder is fine.
115
- export function unsafeStartDir(cwd , home = homedir()) {
237
+ // The one start folder this CLI refuses: the filesystem root, where the working directory would be
238
+ // the whole disk. Anywhere else, the home folder included, it starts like any other coding CLI;
239
+ // secrets there are protected by isSensitivePath, not by refusing to start. Returns the reason,
240
+ // or undefined when the folder is fine.
241
+ export function unsafeStartDir(cwd ) {
116
242
  let real ;
117
- let realHome ;
118
243
  try {
119
244
  real = realpathSync(cwd);
120
245
  } catch {
121
246
  return undefined;
122
247
  }
123
- try {
124
- realHome = realpathSync(home);
125
- } catch {
126
- realHome = home;
127
- }
128
248
  if (real === parse(real).root) return "the filesystem root";
129
- if (real === realHome) return "your home folder";
130
249
  return undefined;
131
250
  }
package/dist/prompt.js ADDED
@@ -0,0 +1,381 @@
1
+ // The interactive prompt: the slash command table, the inline command menu, /help, and the frame
2
+ // drawn around the input line (a rule above, a rule, the status line and key hints below).
3
+ //
4
+ // The frame and menu use only node:readline keypress events, no extra package. Keys from the
5
+ // terminal are read here and forwarded to readline through a PassThrough, so the menu can take the
6
+ // arrow keys, Tab, Enter and Esc for itself while it is open. The only control sequences written
7
+ // are the fixed strings in this file (cursor up and column, erase line); every piece of text is our
8
+ // own table or the user's own typing, already shown by readline.
9
+ import { PassThrough } from "node:stream";
10
+ import { emitKeypressEvents } from "node:readline";
11
+
12
+ import { bold, dim, stone, terracottaBold } from "./theme.js";
13
+ import { fit, oneLine, pad, PLACEHOLDER, rule, styleOf, textWidth } from "./ui.js";
14
+
15
+
16
+
17
+
18
+
19
+ export const SLASH_COMMANDS = [
20
+ { name: "/model", args: "blink|sonata|aeon", desc: "Choose the model (arrow keys)", group: "Session" },
21
+ { name: "/effort", args: "default|low|medium|high", desc: "Set reasoning effort (arrow keys)", group: "Session" },
22
+ { name: "/provider", desc: "Switch provider: Synqa Cloud, OpenCode, ...", group: "Session" },
23
+ { name: "/login", desc: "Sign in to a provider", group: "Session" },
24
+ { name: "/logout", desc: "Sign out of a provider or fully", group: "Session" },
25
+ { name: "/status", desc: "Tier, routing, tokens, credit and weekly pool", group: "Session" },
26
+ { name: "/compact", desc: "Summarize the conversation to save context", group: "Session" },
27
+ { name: "/clear", desc: "Start a fresh conversation", group: "Session" },
28
+ { name: "/init", desc: "Write a starter AGENTS.md for this project", group: "Project" },
29
+ { name: "/undo", desc: "Revert the last file the agent wrote", group: "Project" },
30
+ { name: "/hooks", desc: "List hooks and switch them on or off", group: "General" },
31
+ { name: "/update", desc: "Install the latest @synqa/cli", group: "General" },
32
+ { name: "/help", desc: "Show commands and keys", group: "General" },
33
+ { name: "/exit", desc: "Leave Synqa", group: "General" },
34
+ ];
35
+
36
+ // Commands whose name starts with what was typed, then those that contain it.
37
+ export function matchCommands(typed ) {
38
+ const t = typed.toLowerCase();
39
+ if (!t.startsWith("/")) return [];
40
+ const starts = SLASH_COMMANDS.filter((c) => c.name.startsWith(t));
41
+ const contains = SLASH_COMMANDS.filter((c) => !c.name.startsWith(t) && c.name.includes(t.slice(1)));
42
+ return [...starts, ...contains];
43
+ }
44
+
45
+ function distance(a , b ) {
46
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array (b.length).fill(0)]);
47
+ for (let j = 1; j <= b.length; j++) (d[0] )[j] = j;
48
+ for (let i = 1; i <= a.length; i++) {
49
+ for (let j = 1; j <= b.length; j++) {
50
+ const row = d[i] ;
51
+ const prev = d[i - 1] ;
52
+ row[j] = Math.min((prev[j] ) + 1, (row[j - 1] ) + 1, (prev[j - 1] ) + (a[i - 1] === b[j - 1] ? 0 : 1));
53
+ }
54
+ }
55
+ return (d[a.length] )[b.length] ;
56
+ }
57
+
58
+ // One line for an unknown command, with the nearest name when one is close.
59
+ export function unknownCommandHint(word ) {
60
+ const w = oneLine(word).toLowerCase().slice(0, 40);
61
+ const near = matchCommands(w)[0] ?? SLASH_COMMANDS
62
+ .map((c) => ({ c, d: distance(w, c.name) }))
63
+ .filter((x) => x.d <= 2)
64
+ .sort((a, b) => a.d - b.d)[0]?.c;
65
+ return near ? `Unknown command ${w}. Did you mean ${near.name}? Type / to see all commands.` : `Unknown command ${w}. Type / to see all commands.`;
66
+ }
67
+
68
+ const usage = (c ) => (c.args ? `${c.name} ${c.args}` : c.name);
69
+
70
+ // /help: grouped, aligned, names bold and descriptions dim.
71
+ export function renderHelp(ui ) {
72
+ const s = styleOf(ui);
73
+ const room = ui.columns - 1;
74
+ const w = Math.max(...SLASH_COMMANDS.map((c) => usage(c).length)) + 3;
75
+ const out = [];
76
+ for (const group of ["Session", "Project", "General"] ) {
77
+ out.push(stone(s, group));
78
+ for (const c of SLASH_COMMANDS.filter((x) => x.group === group)) {
79
+ const u = usage(c);
80
+ if (4 + w >= room) { out.push(" " + bold(s, fit(u, room - 2, ui))); continue; }
81
+ out.push(" " + bold(s, c.name) + (c.args ? dim(s, " " + c.args) : "") + " ".repeat(w - u.length) + dim(s, fit(c.desc, room - 2 - w, ui)));
82
+ }
83
+ }
84
+ out.push(dim(s, fit("Type / for the command menu: arrows move, Tab or Enter completes, Esc closes.", room, ui)));
85
+ return out;
86
+ }
87
+
88
+ export const SLASH_HELP = renderHelp({ color: false, unicode: true, tty: false, columns: 100 }).join("\n");
89
+
90
+ const MENU_MAX = 8;
91
+
92
+ // The menu under the input: the selected row has a terracotta pointer and a bold name.
93
+ export function renderMenu(items , selected , ui ) {
94
+ const s = styleOf(ui);
95
+ const room = ui.columns - 1;
96
+ const pointer = ui.unicode ? "❯" : ">";
97
+ const nameW = Math.max(...SLASH_COMMANDS.map((c) => c.name.length)) + 2;
98
+ const start = Math.max(0, Math.min(selected - MENU_MAX + 1, items.length - MENU_MAX));
99
+ return items.slice(start, start + MENU_MAX).map((c, i) => {
100
+ const on = start + i === selected;
101
+ const lead = on ? terracottaBold(s, pointer) + " " : " ";
102
+ const name = pad(c.name, nameW);
103
+ const descRoom = room - 2 - nameW;
104
+ const desc = descRoom > 4 ? fit(c.desc, descRoom, ui) : "";
105
+ if (descRoom <= 4) return lead + (on ? terracottaBold(s, fit(c.name, room - 2, ui)) : fit(c.name, room - 2, ui));
106
+ return lead + (on ? terracottaBold(s, name) + stone(s, desc) : name + dim(s, desc));
107
+ });
108
+ }
109
+
110
+ // ---------------------------------------------------------------- the frame
111
+
112
+
113
+
114
+
115
+
116
+ const ESC = "\x1b";
117
+
118
+ // Draws below the input line and routes keys. One per session, TTY only.
119
+ export class PromptFrame {
120
+ input ;
121
+ rl ;
122
+ shown = 0; // rows drawn below the input line
123
+ active = false; // the main prompt is being answered (not a y/N question, not a turn)
124
+ selected = 0;
125
+ dismissed = false;
126
+ placeholder = false;
127
+ lastLine = "";
128
+ started = false;
129
+ pasting = false; // between the terminal's paste-start and paste-end marks
130
+ ctrlC = false; // Ctrl+C was pressed (typed, not pasted)
131
+ capture ; // a selector or secret prompt owns the keys
132
+ lastCols = 0;
133
+ promptStr = "";
134
+ // Shift+Tab at the prompt (cycles the permission mode); the footer is redrawn after it.
135
+ onShiftTab ;
136
+
137
+ // True once the person pressed Ctrl+C. An end of input is not an interrupt.
138
+ get interrupted() {
139
+ return this.ctrlC;
140
+ }
141
+ onKey = (s , key ) => this.key(s, key);
142
+
143
+ stdin ;
144
+ out ;
145
+ ui ;
146
+ below ; // the status lines
147
+ promptWidth ;
148
+
149
+ paste ; // bracketed paste is switched on (stdout is the terminal)
150
+ pasteOk = false; // a prompt that takes typed text is open: pasted text is typed in
151
+
152
+ // `opts.paste` false when stdout is not the terminal (a piped one-shot): the escape that switches
153
+ // bracketed paste on would land in the output instead.
154
+ constructor(stdin , out , ui , below , promptWidth , opts = {}) {
155
+ this.paste = opts.paste ?? true;
156
+ this.stdin = stdin;
157
+ this.out = out;
158
+ this.ui = ui;
159
+ this.below = below;
160
+ this.promptWidth = promptWidth;
161
+ const pass = new PassThrough() ;
162
+ pass.isTTY = true;
163
+ pass.setRawMode = () => {};
164
+ this.input = pass;
165
+ }
166
+
167
+ start(rl ) {
168
+ this.rl = rl;
169
+ emitKeypressEvents(this.stdin);
170
+ this.stdin.setRawMode?.(true);
171
+ this.stdin.on("keypress", this.onKey);
172
+ this.stdin.resume();
173
+ // Bracketed paste: the terminal marks pasted text, so a pasted newline is not an Enter.
174
+ if (this.paste) this.out.write(`${ESC}[?2004h`);
175
+ this.started = true;
176
+ rl.on("close", () => this.stop());
177
+ }
178
+
179
+ // Gives the terminal back: raw mode and bracketed paste off. Safe to call any number of times.
180
+ stop() {
181
+ if (!this.started) return;
182
+ this.started = false;
183
+ this.stdin.off("keypress", this.onKey);
184
+ if (this.paste) this.out.write(`${ESC}[?2004l`);
185
+ this.stdin.setRawMode?.(false);
186
+ this.stdin.pause();
187
+ this.input.end();
188
+ }
189
+
190
+ // KeySource: gives every key to fn and none to readline until the returned function is called.
191
+ // Pasted text reaches fn only when asked for (a secret), with its line breaks dropped.
192
+ subscribe(fn , opts = {}) {
193
+ const mine = { fn, paste: opts.paste === true };
194
+ this.capture = mine;
195
+ return () => { if (this.capture === mine) this.capture = undefined; };
196
+ }
197
+
198
+ // The window changed: when the columns differ, redraw the whole frame (see redrawFrame).
199
+ onResize() {
200
+ const cols = this.cols();
201
+ const prev = this.lastCols || cols;
202
+ this.lastCols = cols;
203
+ if (this.active && !this.capture && cols !== prev) this.redrawFrame(prev);
204
+ }
205
+
206
+ // Rebuilds the framed prompt at the current width: the old top rule is erased (it may have
207
+ // reflowed onto several rows if the window got narrower), then the top rule, the prompt with the
208
+ // typed text, the bottom rule and the footer are drawn again, so no half-width border is left.
209
+ redrawFrame(prevCols ) {
210
+ if (!this.active) return;
211
+ const cols = this.cols();
212
+ const ruleRows = Math.max(1, Math.ceil(Math.max(1, prevCols - 1) / Math.max(1, cols)));
213
+ this.out.write(`\r${ESC}[${ruleRows}A${ESC}[J` + rule(this.ui) + "\n" + this.promptStr + this.line());
214
+ this.shown = 0;
215
+ this.placeholder = false;
216
+ this.draw();
217
+ }
218
+
219
+ line() {
220
+ return (this.rl )?.line ?? "";
221
+ }
222
+
223
+ menuItems() {
224
+ const l = this.line();
225
+ if (!this.active || this.dismissed || !l.startsWith("/") || /\s/.test(l)) return [];
226
+ return matchCommands(l);
227
+ }
228
+
229
+ // The rule above the prompt; then the prompt itself is asked by the caller.
230
+ async ask(promptText ) {
231
+ const rl = this.rl ;
232
+ this.promptStr = promptText;
233
+ this.lastCols = this.cols();
234
+ this.out.write(rule(this.ui) + "\n");
235
+ this.active = true;
236
+ this.pasteOk = true;
237
+ this.selected = 0;
238
+ this.dismissed = false;
239
+ this.lastLine = "";
240
+ const answer = rl.question(promptText);
241
+ this.draw();
242
+ try {
243
+ return await answer;
244
+ } finally {
245
+ this.active = false;
246
+ this.pasteOk = false;
247
+ }
248
+ }
249
+
250
+ // A plain prompt with no frame or menu (an ASCII terminal): pasted text is typed in, nothing drawn.
251
+ async askPlain(promptText ) {
252
+ this.pasteOk = true;
253
+ try {
254
+ return await (this.rl ).question(promptText);
255
+ } finally {
256
+ this.pasteOk = false;
257
+ }
258
+ }
259
+
260
+ // Erases what is drawn below the input line (before Enter, so the transcript stays clean).
261
+ clear() {
262
+ if (this.shown === 0) return;
263
+ let s = "";
264
+ for (let i = 0; i < this.shown; i++) s += `\r\n${ESC}[2K`;
265
+ s += `${ESC}[${this.shown}A${ESC}[${this.cursorCol()}G`;
266
+ this.out.write(s);
267
+ this.shown = 0;
268
+ }
269
+
270
+ cursorCol() {
271
+ const pos = (this.rl )?.getCursorPos?.();
272
+ return (pos?.cols ?? this.promptWidth + textWidth(this.line())) + 1;
273
+ }
274
+
275
+ cols() {
276
+ return this.ui.columns;
277
+ }
278
+
279
+ // What goes under the input: the rule, then the menu or the status lines.
280
+ belowLines() {
281
+ const items = this.menuItems();
282
+ if (this.selected >= items.length) this.selected = 0;
283
+ const body = items.length ? renderMenu(items, this.selected, this.ui) : this.below();
284
+ return [rule(this.ui), ...body];
285
+ }
286
+
287
+ draw() {
288
+ if (!this.active) return;
289
+ const l = this.line();
290
+ // A line that wraps would put the frame in the wrong place: draw nothing until it is short.
291
+ if (this.promptWidth + textWidth(l) >= this.cols() - 1) { this.clear(); return; }
292
+ const lines = this.belowLines();
293
+ const n = Math.max(lines.length, this.shown);
294
+ let s = "";
295
+ for (let i = 0; i < n; i++) s += `\r\n${ESC}[2K` + (lines[i] ?? "");
296
+ const col = this.cursorCol();
297
+ s += `${ESC}[${n}A${ESC}[${col}G`;
298
+ if (l === "") {
299
+ s += dim(styleOf(this.ui), fit(PLACEHOLDER, this.cols() - 1 - col, this.ui)) + `${ESC}[${col}G`;
300
+ this.placeholder = true;
301
+ } else if (this.placeholder) {
302
+ s = `${ESC}[K` + s;
303
+ this.placeholder = false;
304
+ }
305
+ this.out.write(s);
306
+ this.shown = lines.length;
307
+ }
308
+
309
+ forward(seq ) {
310
+ this.input.write(seq);
311
+ }
312
+
313
+ replaceLine(text ) {
314
+ // ctrl+e (end of line), ctrl+u (delete to the start), then the new text.
315
+ this.forward("\x05\x15" + text);
316
+ }
317
+
318
+ key(s , key ) {
319
+ const seq = key?.sequence ?? s ?? "";
320
+ if (key?.name === "paste-start") { this.pasting = true; return; }
321
+ if (key?.name === "paste-end") { this.pasting = false; if (!this.capture) this.after(); return; }
322
+ if (this.capture) {
323
+ if (this.pasting) {
324
+ const k = key?.name;
325
+ if (this.capture.paste && k !== "return" && k !== "enter" && seq !== "\r" && seq !== "\n") this.capture.fn(s, key);
326
+ return;
327
+ }
328
+ this.capture.fn(s, key);
329
+ return;
330
+ }
331
+ if (!this.pasting && seq === "\x03") this.ctrlC = true;
332
+ if (this.pasting) {
333
+ // Pasted text is never a key press. While a y/N question (or a turn) holds the input it is
334
+ // dropped, so a pasted "y" and newline cannot approve anything. At the prompt it is typed
335
+ // as text, with each newline as a space so a paste never sends the line.
336
+ if (!this.pasteOk) return;
337
+ const name = key?.name;
338
+ this.forward(name === "return" || name === "enter" || seq === "\r" || seq === "\n" ? " " : seq);
339
+ return;
340
+ }
341
+ if (!this.active) { this.forward(seq); return; }
342
+ const items = this.menuItems();
343
+ const name = key?.name;
344
+ if (name === "tab" && key?.shift) { this.onShiftTab?.(); this.draw(); return; }
345
+ // A lone Esc closes the menu (node reports it with meta set on some versions).
346
+ if (items.length && name === "escape") { this.dismissed = true; this.draw(); return; }
347
+ if (items.length && !key?.ctrl && !key?.meta) {
348
+ if (name === "up" || name === "down") {
349
+ this.selected = (this.selected + (name === "up" ? -1 : 1) + items.length) % items.length;
350
+ this.draw();
351
+ return;
352
+ }
353
+ const pick = items[this.selected] ;
354
+ if (name === "tab") { this.replaceLine(pick.args ? pick.name + " " : pick.name); this.after(); return; }
355
+ if (name === "return" || name === "enter") {
356
+ this.clear();
357
+ this.replaceLine(pick.name);
358
+ this.forward("\r");
359
+ return;
360
+ }
361
+ }
362
+ if (name === "return" || name === "enter") { this.clear(); this.forward(seq); return; }
363
+ if (name === "escape" && this.line() === "") return; // nothing to dismiss; a lone Esc is ignored
364
+ this.forward(seq);
365
+ this.after();
366
+ }
367
+
368
+ // Readline handles the forwarded keys on the next tick; redraw after it has.
369
+ after() {
370
+ setImmediate(() => {
371
+ const l = this.line();
372
+ if (l !== this.lastLine) { this.dismissed = false; this.selected = 0; }
373
+ this.lastLine = l;
374
+ this.draw();
375
+ });
376
+ }
377
+
378
+ redraw() {
379
+ this.draw();
380
+ }
381
+ }