@synqa/cli 0.0.0-stage → 1.1.1

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 ADDED
@@ -0,0 +1,243 @@
1
+ import { closeSync, constants, lstatSync, openSync, realpathSync, writeSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { configDir } from "./config.js";
5
+
6
+ export class ConfineError extends Error {}
7
+
8
+ function realOfExisting(p ) {
9
+ // Resolve symlinks on the nearest existing ancestor, then re-append the missing tail.
10
+ // A missing component that is itself a symlink is a dangling link: refuse it, because a
11
+ // write would follow it to wherever it points.
12
+ let cur = p;
13
+ const tail = [];
14
+ for (;;) {
15
+ try {
16
+ const real = realpathSync(cur);
17
+ return tail.length ? join(real, ...tail.reverse()) : real;
18
+ } catch {
19
+ let isLink = false;
20
+ try {
21
+ isLink = lstatSync(cur).isSymbolicLink();
22
+ } catch {
23
+ isLink = false;
24
+ }
25
+ if (isLink) throw new ConfineError(`path goes through a dangling symlink: ${p}`);
26
+ const parent = dirname(cur);
27
+ if (parent === cur) return p;
28
+ tail.push(cur.slice(parent.length).replace(/^[\\/]+/, ""));
29
+ cur = parent;
30
+ }
31
+ }
32
+ }
33
+
34
+ // Returns the absolute path if it stays inside root (after ".." and symlink resolution), else throws.
35
+ export function confine(root , requested ) {
36
+ const realRoot = realpathSync(root);
37
+ const abs = isAbsolute(requested) ? resolve(requested) : resolve(realRoot, requested);
38
+ const real = realOfExisting(abs);
39
+ const rel = relative(realRoot, real);
40
+ if (rel === "") return real;
41
+ if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) {
42
+ throw new ConfineError(`path escapes the working directory: ${requested}`);
43
+ }
44
+ return real;
45
+ }
46
+
47
+ // Re-confines right before writing and refuses to follow a symlink as the last component.
48
+ export function writeConfined(root , requested , content ) {
49
+ const p = confine(root, requested);
50
+ const fd = openSync(p, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o644);
51
+ try {
52
+ writeSync(fd, content);
53
+ } finally {
54
+ closeSync(fd);
55
+ }
56
+ return p;
57
+ }
58
+
59
+ // One form for every comparison: NFKC (full-width and compatibility characters become plain ones)
60
+ // and lower case, because a client Mac's file system is case-insensitive: ".ENV" is ".env".
61
+ export function fold(s ) {
62
+ return s.normalize("NFKC").toLowerCase();
63
+ }
64
+
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
+ ];
83
+ // Exact file names that hold secrets (compared folded).
84
+ export const SENSITIVE_NAMES = [
85
+ ".env", ".envrc", ".netrc", ".npmrc", ".pypirc", ".git-credentials", ".dev.vars", "keys.json", "credentials.json", ".yarnrc.yml", ".pgpass", ".my.cnf",
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);
128
+ const EXAMPLE_SUFFIX = /\.(example|sample|template|dist)$/;
129
+ const EXT_RE = new RegExp(`\\.(${SENSITIVE_EXTENSIONS.map((e) => e.replace(/[.-]/g, "\\$&")).join("|")})$`);
130
+
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;
134
+ }
135
+ return false;
136
+ }
137
+
138
+ const slashed = (p ) => fold(p.split(sep).join("/")).replace(/\/+$/, "");
139
+
140
+ // The home folder, as given and with symlinks resolved, folded.
141
+ function homeForms(home ) {
142
+ const out = [slashed(home)];
143
+ try {
144
+ out.push(slashed(realpathSync(home)));
145
+ } catch {
146
+ // a home that does not exist is compared as given
147
+ }
148
+ return out.filter((h) => h !== "" && h !== "/");
149
+ }
150
+
151
+ // Inside the home folder, everything under a dot entry at its top (~/.zsh_history, ~/.config/**,
152
+ // ~/.cargo/**, any tool's credential store, known or not) and everything under
153
+ // ~/Library is protected. This is the structural rule: it does not depend on naming each store.
154
+ // A project's own dot files (~/proj/.github, ~/proj/.eslintrc) are below a normal folder and stay usable.
155
+ export function homeHidden(abs , home = homedir()) {
156
+ const norm = slashed(abs);
157
+ for (const h of homeForms(home)) {
158
+ if (!norm.startsWith(h + "/")) continue;
159
+ const first = norm.slice(h.length + 1).split("/")[0] ?? "";
160
+ if (first.startsWith(".") || first === "library") return true;
161
+ }
162
+ return false;
163
+ }
164
+
165
+ // True when `cwd` is the home folder itself.
166
+ export function isHomeDir(cwd , home = homedir()) {
167
+ let real = slashed(cwd);
168
+ try {
169
+ real = slashed(realpathSync(cwd));
170
+ } catch {
171
+ // compared as given
172
+ }
173
+ return homeForms(home).includes(real) || homeForms(home).includes(slashed(cwd));
174
+ }
175
+
176
+ // Files and folders that hold secrets: the home folder's dot entries and Library (homeHidden),
177
+ // env files, private keys, cloud, package manager and container credentials, keychains, browser
178
+ // profiles, histories, and this CLI's own config (which holds the API key). Names only, no file is
179
+ // opened. Every segment is folded first (see fold). read_file, write_file and edit_file refuse
180
+ // these outright; grep and glob skip them.
181
+ export function isSensitivePath(abs , cfg = configDir(), home = homedir()) {
182
+ if (homeHidden(abs, home)) return true;
183
+ const norm = slashed(abs);
184
+ const parts = norm.split("/").filter(Boolean);
185
+ if (parts.some((d) => DIR_SET.has(d))) return true;
186
+ if (SENSITIVE_RUNS.some((run) => hasRun(parts, run))) return true;
187
+ const c = slashed(cfg);
188
+ if (c && (norm === c || norm.startsWith(c + "/"))) return true;
189
+ const name = parts[parts.length - 1] ?? "";
190
+ // Remotes can hold tokens: .git/config, .git/config.worktree, .git/worktrees/<name>/config, at any depth.
191
+ if (parts.slice(0, -1).includes(".git") && (name === "config" || name.startsWith("config."))) return true;
192
+ if (NAME_SET.has(name)) return true;
193
+ if (EXAMPLE_SUFFIX.test(name)) return false;
194
+ if (EXT_RE.test(name)) return true;
195
+ const source = SOURCE_RE.test(name);
196
+ return SENSITIVE_PATTERNS.some((x) => x.name.test(name) && !(x.word && source));
197
+ }
198
+
199
+ const escRe = (s ) => s.replace(/[.*+?^${}()|[\]\\/-]/g, "\\$&");
200
+
201
+ // Names a shell command can use to reach a secret, including globs that expand to them, built from
202
+ // the same tables as isSensitivePath. Heuristic: it makes an unattended (--yes) command ask when it
203
+ // looks at secrets, it does not make bash safe.
204
+ const SECRET_WORDS = [
205
+ ...SENSITIVE_DIRS.map((d) => new RegExp(escRe(d))),
206
+ ...SENSITIVE_RUNS.map((r) => new RegExp(r.map(escRe).join("/"))),
207
+ ...SENSITIVE_NAMES.map((n) => new RegExp(escRe(n))),
208
+ ...RUN_FOLDERS.map((f) => new RegExp(`${escRe(f)}\\b`)),
209
+ new RegExp(`\\.(${SENSITIVE_EXTENSIONS.map(escRe).join("|")})\\b`),
210
+ ...SENSITIVE_PATTERNS.map((x) => x.shell),
211
+ /\.git\/config/,
212
+ // Dumping the environment (where tokens live): printenv, env or export -p or set with no arguments.
213
+ /\bprintenv\b/, /(^|[;&|(]\s*)env\s*($|[;&|)>])/, /\bexport\s+-p\b/, /(^|[;&|(]\s*)(set|declare\s+-x)\s*($|[;&|)>])/,
214
+ /\.config\/synqa/, /synqa\/config/,
215
+ /(^|[\s/])\.[\w.-]*[*?[]/, // a dot glob such as .e* or .*
216
+ /~\/?\./, /\$\{?home\}?\/?\./, /~\/library/, /\$\{?home\}?\/library/, // the home folder's dot entries and Library
217
+ /\bsecurity\s+(find|dump|export)-/, /\.binarycookies/,
218
+ ];
219
+
220
+ export function commandTouchesSecrets(command , cfg = configDir()) {
221
+ // Escapes that can spell any name ($'\x2eenv', \056, printf '\x2e'): always ask.
222
+ if (/\$'|\\x[0-9a-f]{2}|\\[0-7]{3}|\\u[0-9a-f]{4}/i.test(command)) return true;
223
+ // Quotes and backslashes are dropped first so .e""nv and .\env read as .env.
224
+ const c = fold(command).replace(/["'\\`]/g, "");
225
+ if (SECRET_WORDS.some((re) => re.test(c))) return true;
226
+ const dir = fold(cfg);
227
+ return dir !== "" && c.includes(dir);
228
+ }
229
+
230
+ // The one start folder this CLI refuses: the filesystem root, where the working directory would be
231
+ // the whole disk. Anywhere else, the home folder included, it starts like any other coding CLI;
232
+ // secrets there are protected by isSensitivePath, not by refusing to start. Returns the reason,
233
+ // or undefined when the folder is fine.
234
+ export function unsafeStartDir(cwd ) {
235
+ let real ;
236
+ try {
237
+ real = realpathSync(cwd);
238
+ } catch {
239
+ return undefined;
240
+ }
241
+ if (real === parse(real).root) return "the filesystem root";
242
+ return undefined;
243
+ }
package/dist/prompt.js ADDED
@@ -0,0 +1,330 @@
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
+ export const SLASH_COMMANDS = [
19
+ { name: "/model", args: "blink|sonata|aeon", desc: "Switch tier", group: "Session" },
20
+ { name: "/effort", args: "default|low|medium", desc: "Set reasoning effort", group: "Session" },
21
+ { name: "/status", desc: "Tier, routing, tokens, credit and weekly pool", group: "Session" },
22
+ { name: "/compact", desc: "Summarize the conversation to save context", group: "Session" },
23
+ { name: "/clear", desc: "Start a fresh conversation", group: "Session" },
24
+ { name: "/init", desc: "Write a starter AGENTS.md for this project", group: "Project" },
25
+ { name: "/undo", desc: "Revert the last file the agent wrote", group: "Project" },
26
+ { name: "/help", desc: "Show commands and keys", group: "General" },
27
+ { name: "/exit", desc: "Leave Synqa", group: "General" },
28
+ ];
29
+
30
+ // Commands whose name starts with what was typed, then those that contain it.
31
+ export function matchCommands(typed ) {
32
+ const t = typed.toLowerCase();
33
+ if (!t.startsWith("/")) return [];
34
+ const starts = SLASH_COMMANDS.filter((c) => c.name.startsWith(t));
35
+ const contains = SLASH_COMMANDS.filter((c) => !c.name.startsWith(t) && c.name.includes(t.slice(1)));
36
+ return [...starts, ...contains];
37
+ }
38
+
39
+ function distance(a , b ) {
40
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array (b.length).fill(0)]);
41
+ for (let j = 1; j <= b.length; j++) (d[0] )[j] = j;
42
+ for (let i = 1; i <= a.length; i++) {
43
+ for (let j = 1; j <= b.length; j++) {
44
+ const row = d[i] ;
45
+ const prev = d[i - 1] ;
46
+ row[j] = Math.min((prev[j] ) + 1, (row[j - 1] ) + 1, (prev[j - 1] ) + (a[i - 1] === b[j - 1] ? 0 : 1));
47
+ }
48
+ }
49
+ return (d[a.length] )[b.length] ;
50
+ }
51
+
52
+ // One line for an unknown command, with the nearest name when one is close.
53
+ export function unknownCommandHint(word ) {
54
+ const w = oneLine(word).toLowerCase().slice(0, 40);
55
+ const near = matchCommands(w)[0] ?? SLASH_COMMANDS
56
+ .map((c) => ({ c, d: distance(w, c.name) }))
57
+ .filter((x) => x.d <= 2)
58
+ .sort((a, b) => a.d - b.d)[0]?.c;
59
+ return near ? `Unknown command ${w}. Did you mean ${near.name}? Type / to see all commands.` : `Unknown command ${w}. Type / to see all commands.`;
60
+ }
61
+
62
+ const usage = (c ) => (c.args ? `${c.name} ${c.args}` : c.name);
63
+
64
+ // /help: grouped, aligned, names bold and descriptions dim.
65
+ export function renderHelp(ui ) {
66
+ const s = styleOf(ui);
67
+ const room = ui.columns - 1;
68
+ const w = Math.max(...SLASH_COMMANDS.map((c) => usage(c).length)) + 3;
69
+ const out = [];
70
+ for (const group of ["Session", "Project", "General"] ) {
71
+ out.push(stone(s, group));
72
+ for (const c of SLASH_COMMANDS.filter((x) => x.group === group)) {
73
+ const u = usage(c);
74
+ if (4 + w >= room) { out.push(" " + bold(s, fit(u, room - 2, ui))); continue; }
75
+ 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)));
76
+ }
77
+ }
78
+ out.push(dim(s, fit("Type / for the command menu: arrows move, Tab or Enter completes, Esc closes.", room, ui)));
79
+ return out;
80
+ }
81
+
82
+ export const SLASH_HELP = renderHelp({ color: false, unicode: true, tty: false, columns: 100 }).join("\n");
83
+
84
+ const MENU_MAX = 8;
85
+
86
+ // The menu under the input: the selected row has a terracotta pointer and a bold name.
87
+ export function renderMenu(items , selected , ui ) {
88
+ const s = styleOf(ui);
89
+ const room = ui.columns - 1;
90
+ const pointer = ui.unicode ? "❯" : ">";
91
+ const nameW = Math.max(...SLASH_COMMANDS.map((c) => c.name.length)) + 2;
92
+ const start = Math.max(0, Math.min(selected - MENU_MAX + 1, items.length - MENU_MAX));
93
+ return items.slice(start, start + MENU_MAX).map((c, i) => {
94
+ const on = start + i === selected;
95
+ const lead = on ? terracottaBold(s, pointer) + " " : " ";
96
+ const name = pad(c.name, nameW);
97
+ const descRoom = room - 2 - nameW;
98
+ const desc = descRoom > 4 ? fit(c.desc, descRoom, ui) : "";
99
+ if (descRoom <= 4) return lead + (on ? terracottaBold(s, fit(c.name, room - 2, ui)) : fit(c.name, room - 2, ui));
100
+ return lead + (on ? terracottaBold(s, name) + stone(s, desc) : name + dim(s, desc));
101
+ });
102
+ }
103
+
104
+ // ---------------------------------------------------------------- the frame
105
+
106
+
107
+
108
+
109
+
110
+ const ESC = "\x1b";
111
+
112
+ // Draws below the input line and routes keys. One per session, TTY only.
113
+ export class PromptFrame {
114
+ input ;
115
+ rl ;
116
+ shown = 0; // rows drawn below the input line
117
+ active = false; // the main prompt is being answered (not a y/N question, not a turn)
118
+ selected = 0;
119
+ dismissed = false;
120
+ placeholder = false;
121
+ lastLine = "";
122
+ started = false;
123
+ pasting = false; // between the terminal's paste-start and paste-end marks
124
+ ctrlC = false; // Ctrl+C was pressed (typed, not pasted)
125
+
126
+ // True once the person pressed Ctrl+C. An end of input is not an interrupt.
127
+ get interrupted() {
128
+ return this.ctrlC;
129
+ }
130
+ onKey = (s , key ) => this.key(s, key);
131
+
132
+ stdin ;
133
+ out ;
134
+ ui ;
135
+ below ; // the status lines
136
+ promptWidth ;
137
+
138
+ paste ; // bracketed paste is switched on (stdout is the terminal)
139
+ pasteOk = false; // a prompt that takes typed text is open: pasted text is typed in
140
+
141
+ // `opts.paste` false when stdout is not the terminal (a piped one-shot): the escape that switches
142
+ // bracketed paste on would land in the output instead.
143
+ constructor(stdin , out , ui , below , promptWidth , opts = {}) {
144
+ this.paste = opts.paste ?? true;
145
+ this.stdin = stdin;
146
+ this.out = out;
147
+ this.ui = ui;
148
+ this.below = below;
149
+ this.promptWidth = promptWidth;
150
+ const pass = new PassThrough() ;
151
+ pass.isTTY = true;
152
+ pass.setRawMode = () => {};
153
+ this.input = pass;
154
+ }
155
+
156
+ start(rl ) {
157
+ this.rl = rl;
158
+ emitKeypressEvents(this.stdin);
159
+ this.stdin.setRawMode?.(true);
160
+ this.stdin.on("keypress", this.onKey);
161
+ this.stdin.resume();
162
+ // Bracketed paste: the terminal marks pasted text, so a pasted newline is not an Enter.
163
+ if (this.paste) this.out.write(`${ESC}[?2004h`);
164
+ this.started = true;
165
+ rl.on("close", () => this.stop());
166
+ }
167
+
168
+ // Gives the terminal back: raw mode and bracketed paste off. Safe to call any number of times.
169
+ stop() {
170
+ if (!this.started) return;
171
+ this.started = false;
172
+ this.stdin.off("keypress", this.onKey);
173
+ if (this.paste) this.out.write(`${ESC}[?2004l`);
174
+ this.stdin.setRawMode?.(false);
175
+ this.stdin.pause();
176
+ this.input.end();
177
+ }
178
+
179
+ line() {
180
+ return (this.rl )?.line ?? "";
181
+ }
182
+
183
+ menuItems() {
184
+ const l = this.line();
185
+ if (!this.active || this.dismissed || !l.startsWith("/") || /\s/.test(l)) return [];
186
+ return matchCommands(l);
187
+ }
188
+
189
+ // The rule above the prompt; then the prompt itself is asked by the caller.
190
+ async ask(promptText ) {
191
+ const rl = this.rl ;
192
+ this.out.write(rule(this.ui) + "\n");
193
+ this.active = true;
194
+ this.pasteOk = true;
195
+ this.selected = 0;
196
+ this.dismissed = false;
197
+ this.lastLine = "";
198
+ const answer = rl.question(promptText);
199
+ this.draw();
200
+ try {
201
+ return await answer;
202
+ } finally {
203
+ this.active = false;
204
+ this.pasteOk = false;
205
+ }
206
+ }
207
+
208
+ // A plain prompt with no frame or menu (an ASCII terminal): pasted text is typed in, nothing drawn.
209
+ async askPlain(promptText ) {
210
+ this.pasteOk = true;
211
+ try {
212
+ return await (this.rl ).question(promptText);
213
+ } finally {
214
+ this.pasteOk = false;
215
+ }
216
+ }
217
+
218
+ // Erases what is drawn below the input line (before Enter, so the transcript stays clean).
219
+ clear() {
220
+ if (this.shown === 0) return;
221
+ let s = "";
222
+ for (let i = 0; i < this.shown; i++) s += `\r\n${ESC}[2K`;
223
+ s += `${ESC}[${this.shown}A${ESC}[${this.cursorCol()}G`;
224
+ this.out.write(s);
225
+ this.shown = 0;
226
+ }
227
+
228
+ cursorCol() {
229
+ const pos = (this.rl )?.getCursorPos?.();
230
+ return (pos?.cols ?? this.promptWidth + textWidth(this.line())) + 1;
231
+ }
232
+
233
+ cols() {
234
+ return this.ui.columns;
235
+ }
236
+
237
+ // What goes under the input: the rule, then the menu or the status lines.
238
+ belowLines() {
239
+ const items = this.menuItems();
240
+ if (this.selected >= items.length) this.selected = 0;
241
+ const body = items.length ? renderMenu(items, this.selected, this.ui) : this.below();
242
+ return [rule(this.ui), ...body];
243
+ }
244
+
245
+ draw() {
246
+ if (!this.active) return;
247
+ const l = this.line();
248
+ // A line that wraps would put the frame in the wrong place: draw nothing until it is short.
249
+ if (this.promptWidth + textWidth(l) >= this.cols() - 1) { this.clear(); return; }
250
+ const lines = this.belowLines();
251
+ const n = Math.max(lines.length, this.shown);
252
+ let s = "";
253
+ for (let i = 0; i < n; i++) s += `\r\n${ESC}[2K` + (lines[i] ?? "");
254
+ const col = this.cursorCol();
255
+ s += `${ESC}[${n}A${ESC}[${col}G`;
256
+ if (l === "") {
257
+ s += dim(styleOf(this.ui), fit(PLACEHOLDER, this.cols() - 1 - col, this.ui)) + `${ESC}[${col}G`;
258
+ this.placeholder = true;
259
+ } else if (this.placeholder) {
260
+ s = `${ESC}[K` + s;
261
+ this.placeholder = false;
262
+ }
263
+ this.out.write(s);
264
+ this.shown = lines.length;
265
+ }
266
+
267
+ forward(seq ) {
268
+ this.input.write(seq);
269
+ }
270
+
271
+ replaceLine(text ) {
272
+ // ctrl+e (end of line), ctrl+u (delete to the start), then the new text.
273
+ this.forward("\x05\x15" + text);
274
+ }
275
+
276
+ key(s , key ) {
277
+ const seq = key?.sequence ?? s ?? "";
278
+ if (key?.name === "paste-start") { this.pasting = true; return; }
279
+ if (key?.name === "paste-end") { this.pasting = false; this.after(); return; }
280
+ if (!this.pasting && seq === "\x03") this.ctrlC = true;
281
+ if (this.pasting) {
282
+ // Pasted text is never a key press. While a y/N question (or a turn) holds the input it is
283
+ // dropped, so a pasted "y" and newline cannot approve anything. At the prompt it is typed
284
+ // as text, with each newline as a space so a paste never sends the line.
285
+ if (!this.pasteOk) return;
286
+ const name = key?.name;
287
+ this.forward(name === "return" || name === "enter" || seq === "\r" || seq === "\n" ? " " : seq);
288
+ return;
289
+ }
290
+ if (!this.active) { this.forward(seq); return; }
291
+ const items = this.menuItems();
292
+ const name = key?.name;
293
+ // A lone Esc closes the menu (node reports it with meta set on some versions).
294
+ if (items.length && name === "escape") { this.dismissed = true; this.draw(); return; }
295
+ if (items.length && !key?.ctrl && !key?.meta) {
296
+ if (name === "up" || name === "down") {
297
+ this.selected = (this.selected + (name === "up" ? -1 : 1) + items.length) % items.length;
298
+ this.draw();
299
+ return;
300
+ }
301
+ const pick = items[this.selected] ;
302
+ if (name === "tab") { this.replaceLine(pick.args ? pick.name + " " : pick.name); this.after(); return; }
303
+ if (name === "return" || name === "enter") {
304
+ if (pick.args && this.line().toLowerCase() !== pick.name) { this.replaceLine(pick.name + " "); this.after(); return; }
305
+ this.clear();
306
+ this.replaceLine(pick.name);
307
+ this.forward("\r");
308
+ return;
309
+ }
310
+ }
311
+ if (name === "return" || name === "enter") { this.clear(); this.forward(seq); return; }
312
+ if (name === "escape" && this.line() === "") return; // nothing to dismiss; a lone Esc is ignored
313
+ this.forward(seq);
314
+ this.after();
315
+ }
316
+
317
+ // Readline handles the forwarded keys on the next tick; redraw after it has.
318
+ after() {
319
+ setImmediate(() => {
320
+ const l = this.line();
321
+ if (l !== this.lastLine) { this.dismissed = false; this.selected = 0; }
322
+ this.lastLine = l;
323
+ this.draw();
324
+ });
325
+ }
326
+
327
+ redraw() {
328
+ this.draw();
329
+ }
330
+ }
@@ -0,0 +1,53 @@
1
+ // Model-controlled text must never reach the terminal as control sequences.
2
+
3
+ // OSC (ESC ] ... BEL or ST), CSI (ESC [ ... final byte), and any other two-byte ESC sequence.
4
+ const ESC_SEQ = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]?|\x1b[@-_]?/g;
5
+ // C0 except \n and \t, DEL, and C1.
6
+ const CONTROL = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
7
+ // Bidi controls and invisible characters that can reorder or hide text, as inclusive code point
8
+ // ranges: U+061C, U+200B-200F, U+2028-2029, U+202A-202E, U+2060-2064, U+2066-2069, U+FEFF. Built from numbers so this
9
+ // source file itself holds none of them.
10
+ const INVISIBLE_RANGES = [
11
+ [0x061c, 0x061c],
12
+ [0x200b, 0x200f],
13
+ [0x2028, 0x2029],
14
+ [0x202a, 0x202e],
15
+ [0x2060, 0x2064],
16
+ [0x2066, 0x2069],
17
+ [0xfeff, 0xfeff],
18
+ ];
19
+ const hex4 = (n ) => n.toString(16).padStart(4, "0");
20
+ const INVISIBLE = new RegExp(
21
+ "[" + INVISIBLE_RANGES.map(([a, b]) => `\\u${hex4(a)}-\\u${hex4(b)}`).join("") + "]",
22
+ "g",
23
+ );
24
+
25
+ export function sanitize(s ) {
26
+ return s.replace(ESC_SEQ, "").replace(CONTROL, "").replace(INVISIBLE, "");
27
+ }
28
+
29
+ // For text the user must judge before approving (shell commands, paths): show escapes literally.
30
+ export function visibleEscapes(s ) {
31
+ return s
32
+ .replace(/[\x00-\x08\x0a-\x1f\x7f-\x9f]/g, (c) => {
33
+ if (c === "\n") return "\\n";
34
+ if (c === "\r") return "\\r";
35
+ return "\\x" + c.charCodeAt(0).toString(16).padStart(2, "0");
36
+ })
37
+ .replace(INVISIBLE, (c) => "\\u" + hex4(c.charCodeAt(0)));
38
+ }
39
+
40
+ // Untrusted text that goes inside a fenced block of the prompt (AGENTS.md, the compact summary).
41
+ // Order matters: NFKC first (full-width and compatibility forms become plain ones), then every
42
+ // format and default-ignorable character is removed (soft hyphen, zero-width and bidi marks, the
43
+ // word joiner range, BOM: they could split a tag so a match misses it), then the cap, so the
44
+ // normalization cannot grow the text past it, and last every "<" becomes "\u2039" so no tag, in any
45
+ // spelling, can open or close the fence.
46
+ export function fenceText(s , max ) {
47
+ return s
48
+ .normalize("NFKC")
49
+ .replace(/[\p{Cf}\u00ad\u034f\u115f\u1160\u17b4\u17b5\u180b-\u180f\u2060-\u206f\u3164\ufe00-\ufe0f\uffa0]/gu, "")
50
+ .trim()
51
+ .slice(0, max)
52
+ .replace(/</g, "\u2039");
53
+ }