ucode-agent 1.63.0 → 1.64.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.
@@ -0,0 +1,148 @@
1
+ /**
2
+ * settings.js — what ucode may do without asking, and what runs around its work.
3
+ *
4
+ * ~/.ucode/settings.json yours, in every project
5
+ * <project>/.ucode/settings.json this project's, on top of yours
6
+ *
7
+ * {
8
+ * "commands": "ask", ask before every command ("auto", the default, runs them)
9
+ * "allow": ["npm test"], never asked about: a command by its first words, or "mcp:server__tool"
10
+ * "hooks": {
11
+ * "afterEdit": ["npx prettier --write {files}"],
12
+ * "beforeCommand": ["node guard.js"] a non-zero exit stops the command
13
+ * }
14
+ * }
15
+ *
16
+ * A project's own hooks and MCP servers run commands on this computer, and a
17
+ * cloned repository can carry any it likes. So they run only once the user
18
+ * has said yes to them, and saying yes is remembered against exactly what was
19
+ * approved: change the hooks and ucode asks again.
20
+ */
21
+
22
+ import { spawn } from 'node:child_process';
23
+ import { createHash } from 'node:crypto';
24
+ import { promises as fs } from 'node:fs';
25
+ import os from 'node:os';
26
+ import path from 'node:path';
27
+
28
+ const UCODE_DIR = path.join(os.homedir(), '.ucode');
29
+ export const USER_SETTINGS = path.join(UCODE_DIR, 'settings.json');
30
+ export const TRUST_FILE = path.join(UCODE_DIR, 'trusted.json');
31
+ export const projectSettingsFile = (cwd) => path.join(cwd, '.ucode', 'settings.json');
32
+
33
+ export async function readJson(file) {
34
+ try {
35
+ const data = JSON.parse(await fs.readFile(file, 'utf8'));
36
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
37
+ } catch {
38
+ return {};
39
+ }
40
+ }
41
+
42
+ export async function writeJson(file, data) {
43
+ await fs.mkdir(path.dirname(file), { recursive: true });
44
+ const temp = `${file}.${process.pid}.tmp`;
45
+ await fs.writeFile(temp, `${JSON.stringify(data, null, 2)}\n`);
46
+ await fs.rename(temp, file);
47
+ }
48
+
49
+ const list = (v) => (Array.isArray(v) ? v : v ? [v] : []).map(String).filter((s) => s.trim());
50
+
51
+ /** Both files, merged: allow lists add up, the project's "commands" wins. */
52
+ export async function loadSettings(cwd, { userFile = USER_SETTINGS } = {}) {
53
+ const [user, project] = await Promise.all([readJson(userFile), readJson(projectSettingsFile(cwd))]);
54
+ const hooks = (s, key) => list(s.hooks?.[key]);
55
+ return {
56
+ commands: (project.commands ?? user.commands) === 'ask' ? 'ask' : 'auto',
57
+ allow: [...new Set([...list(user.allow), ...list(project.allow)])],
58
+ hooks: { afterEdit: hooks(user, 'afterEdit'), beforeCommand: hooks(user, 'beforeCommand') },
59
+ projectHooks: { afterEdit: hooks(project, 'afterEdit'), beforeCommand: hooks(project, 'beforeCommand') },
60
+ };
61
+ }
62
+
63
+ /**
64
+ * What "always allow" remembers: "npm test" for `npm test -- --watch`. Never
65
+ * shorter than a flag: allowing `node -e "…"` once must not allow every
66
+ * `node -e` after it, so a command whose second word is a flag is kept whole.
67
+ */
68
+ export function commandKey(command) {
69
+ const words = String(command).trim().split(/\s+/).filter(Boolean);
70
+ if (!words[1] || words[1].startsWith('-')) return words.join(' ');
71
+ return words.slice(0, 2).join(' ');
72
+ }
73
+
74
+ /** Is this command (or "mcp:name") on the allow list? A whole-word prefix counts. */
75
+ export function isAllowed(allow, command) {
76
+ const c = String(command).trim();
77
+ // Chained commands are never covered by an entry for their first part.
78
+ if (/[;&|`$<>]/.test(c) && !c.startsWith('mcp:')) return false;
79
+ return allow.some((a) => c === a || c.startsWith(`${a} `));
80
+ }
81
+
82
+ /** Add an entry to this project's allow list. */
83
+ export async function addAllow(cwd, entry) {
84
+ const file = projectSettingsFile(cwd);
85
+ const settings = await readJson(file);
86
+ const allow = list(settings.allow);
87
+ if (!allow.includes(entry)) await writeJson(file, { ...settings, allow: [...allow, entry] });
88
+ }
89
+
90
+ /**
91
+ * What was approved, down to the scripts it runs. Hashing only the settings
92
+ * text would let `node .ucode/guard.js` be approved and guard.js swapped
93
+ * afterwards, so every word of the commands that names a file in the project
94
+ * adds that file's contents. (A package fetched by npx is not covered: pin
95
+ * its version in the command to approve one release.)
96
+ */
97
+ async function fingerprint(cwd, value) {
98
+ const hash = createHash('sha1').update(JSON.stringify(value));
99
+ const words = JSON.stringify(value).match(/[\w./\\@-]+\.\w+/g) ?? [];
100
+ for (const word of [...new Set(words)].sort()) {
101
+ const abs = path.resolve(cwd, word);
102
+ if (!abs.startsWith(path.resolve(cwd))) continue;
103
+ const text = await fs.readFile(abs).catch(() => null);
104
+ if (text) hash.update(`\0${word}\0`).update(text);
105
+ }
106
+ return hash.digest('hex');
107
+ }
108
+
109
+ /** Has the user approved exactly this (the project's hooks, its MCP servers) here before? */
110
+ export async function isTrusted(cwd, what, value, file = TRUST_FILE) {
111
+ const all = await readJson(file);
112
+ return all[`${path.resolve(cwd)}#${what}`] === (await fingerprint(cwd, value));
113
+ }
114
+
115
+ export async function trust(cwd, what, value, file = TRUST_FILE) {
116
+ const all = await readJson(file);
117
+ await writeJson(file, { ...all, [`${path.resolve(cwd)}#${what}`]: await fingerprint(cwd, value) });
118
+ }
119
+
120
+ /** A path safe to put on a shell command line as it is. */
121
+ const SAFE_PATH = /^[\w./\\ @+~-]+$/;
122
+
123
+ /**
124
+ * Run one hook. `{files}` becomes the changed files, quoted; they are also in
125
+ * UCODE_FILES. Resolves to { code, output } and never throws.
126
+ */
127
+ export function runHook(command, { cwd, files = [], env = {}, timeout = 30_000 } = {}) {
128
+ const quoted = files.filter((f) => SAFE_PATH.test(f)).map((f) => `"${f}"`).join(' ');
129
+ const line = String(command).includes('{files}') ? String(command).split('{files}').join(quoted) : String(command);
130
+ return new Promise((resolve) => {
131
+ let output = '';
132
+ let child;
133
+ try {
134
+ child = spawn(line, {
135
+ cwd, shell: true, windowsHide: true,
136
+ env: { ...process.env, ...env, UCODE_FILES: files.join('\n') },
137
+ });
138
+ } catch (err) {
139
+ resolve({ code: -1, output: err.message });
140
+ return;
141
+ }
142
+ const timer = setTimeout(() => { output += `\n(stopped after ${timeout / 1000}s)`; child.kill(); }, timeout);
143
+ child.stdout?.on('data', (d) => { output += d; });
144
+ child.stderr?.on('data', (d) => { output += d; });
145
+ child.on('error', (err) => { clearTimeout(timer); resolve({ code: -1, output: err.message }); });
146
+ child.on('close', (code) => { clearTimeout(timer); resolve({ code: code ?? -1, output: output.trim() }); });
147
+ });
148
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * snapshot.js — the whole project as it was before each turn, for /undo.
3
+ *
4
+ * undo.js remembers files ucode's own tools write, for one turn. That misses
5
+ * everything a command changes — a script that rewrites a config, a generator
6
+ * that adds twenty files — and it forgets the moment the next turn starts. So
7
+ * the project is snapshotted before every turn into a hidden git repository
8
+ * of ucode's own, under ~/.ucode/snapshots: the project's own .git is never
9
+ * read or written, and a folder with no git at all gets the same undo.
10
+ *
11
+ * The project's .gitignore is honoured, and node_modules and build output are
12
+ * always left out: they are huge, and an install can simply be run again.
13
+ *
14
+ * ponytail: snapshots are never pruned. Git only stores what changed, so a
15
+ * long-lived project grows by its edits; `git gc` in the snapshot folder, or
16
+ * deleting it, is the upgrade path if that ever matters.
17
+ */
18
+
19
+ import { spawn } from 'node:child_process';
20
+ import { createHash } from 'node:crypto';
21
+ import { promises as fs } from 'node:fs';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+ import { walk } from '../tools/shared.js';
25
+
26
+ export const SNAPSHOT_HOME = path.join(os.homedir(), '.ucode', 'snapshots');
27
+
28
+ /** Past this many files, snapshotting every turn would cost real time. */
29
+ export const MAX_FILES = 3000;
30
+
31
+ const EXCLUDE = [
32
+ 'node_modules/', '.next/', 'dist/', 'build/', 'out/', 'coverage/', '.cache/', '.turbo/',
33
+ '.venv/', 'venv/', '__pycache__/', 'target/', '.ucode/', '*.log',
34
+ ];
35
+
36
+ function run(args, { cwd }) {
37
+ return new Promise((resolve) => {
38
+ let out = '';
39
+ let err = '';
40
+ let child;
41
+ try {
42
+ child = spawn('git', args, { cwd, windowsHide: true, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } });
43
+ } catch (e) {
44
+ resolve({ code: -1, out: '', err: e.message });
45
+ return;
46
+ }
47
+ child.stdout.on('data', (d) => { out += d; });
48
+ child.stderr.on('data', (d) => { err += d; });
49
+ child.on('error', (e) => resolve({ code: -1, out, err: e.message }));
50
+ child.on('close', (code) => resolve({ code, out, err }));
51
+ });
52
+ }
53
+
54
+ export class Snapshots {
55
+ constructor(cwd, { home = SNAPSHOT_HOME } = {}) {
56
+ this.cwd = path.resolve(cwd);
57
+ const id = createHash('sha1').update(this.cwd.toLowerCase()).digest('hex').slice(0, 16);
58
+ this.gitDir = path.join(home, `${id}.git`);
59
+ this.usable = null; // decided once, by ready()
60
+ }
61
+
62
+ git(args) {
63
+ return run([
64
+ `--git-dir=${this.gitDir}`, `--work-tree=${this.cwd}`,
65
+ '-c', 'core.autocrlf=false', '-c', 'core.safecrlf=false', '-c', 'core.longpaths=true',
66
+ '-c', 'user.name=ucode', '-c', 'user.email=ucode@localhost', '-c', 'commit.gpgsign=false',
67
+ ...args,
68
+ ], { cwd: this.cwd });
69
+ }
70
+
71
+ /**
72
+ * Whether this folder can be snapshotted: git is installed, the folder is a
73
+ * project rather than a home directory or a drive, and it is small enough
74
+ * that a snapshot costs a moment.
75
+ */
76
+ async ready() {
77
+ if (this.usable !== null) return this.usable;
78
+ this.usable = false;
79
+ if (process.env.UCODE_SNAPSHOTS === '0') return false;
80
+ const home = path.resolve(os.homedir());
81
+ if (this.cwd === home || this.cwd === path.parse(this.cwd).root) return false;
82
+ const files = await walk(this.cwd, { limit: MAX_FILES + 1 }).catch(() => null);
83
+ if (!files || files.length > MAX_FILES) return false;
84
+ try {
85
+ await fs.access(path.join(this.gitDir, 'HEAD'));
86
+ } catch {
87
+ await fs.mkdir(path.dirname(this.gitDir), { recursive: true });
88
+ const init = await run(['init', '-q', '--bare', this.gitDir], { cwd: path.dirname(this.gitDir) });
89
+ if (init.code !== 0) return false;
90
+ }
91
+ await fs.mkdir(path.join(this.gitDir, 'info'), { recursive: true });
92
+ await fs.writeFile(path.join(this.gitDir, 'info', 'exclude'), `${EXCLUDE.join('\n')}\n`);
93
+ this.usable = true;
94
+ return true;
95
+ }
96
+
97
+ /** Record the project as it is now. Resolves to the snapshot id, or null. */
98
+ async take(label = 'snapshot') {
99
+ if (!(await this.ready())) return null;
100
+ const add = await this.git(['add', '-A', '.']);
101
+ if (add.code !== 0) return null;
102
+ const commit = await this.git(['commit', '-q', '--allow-empty', '--no-verify', '-m', label]);
103
+ if (commit.code !== 0) return null;
104
+ const head = await this.git(['rev-parse', 'HEAD']);
105
+ return head.code === 0 ? head.out.trim() : null;
106
+ }
107
+
108
+ /** Files that differ between a snapshot and now: [{ status: 'A'|'M'|'D', file }]. */
109
+ async changedSince(id) {
110
+ const now = await this.take('now');
111
+ if (!now) return [];
112
+ const diff = await this.git(['diff', '--name-status', '--no-renames', id, now]);
113
+ if (diff.code !== 0) return [];
114
+ return diff.out.split('\n').filter(Boolean).map((line) => {
115
+ const [status, ...rest] = line.split('\t');
116
+ return { status: status[0], file: rest.join('\t') };
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Put the project back the way it was at a snapshot. Files made since are
122
+ * removed; files changed or deleted since come back.
123
+ */
124
+ async restore(id) {
125
+ const out = { restored: [], removed: [], failed: [] };
126
+ const changes = await this.changedSince(id);
127
+ for (const { status, file } of changes) {
128
+ if (status === 'A') {
129
+ try {
130
+ await fs.rm(path.join(this.cwd, file), { force: true });
131
+ out.removed.push(file);
132
+ } catch (err) {
133
+ out.failed.push(`${file}: ${err.message}`);
134
+ }
135
+ }
136
+ }
137
+ const back = changes.filter((c) => c.status !== 'A').map((c) => c.file);
138
+ // In batches: Windows caps a command line at 32K characters.
139
+ for (let i = 0; i < back.length; i += 100) {
140
+ const batch = back.slice(i, i + 100);
141
+ const checkout = await this.git(['checkout', id, '--', ...batch]);
142
+ if (checkout.code === 0) out.restored.push(...batch);
143
+ else out.failed.push(checkout.err.trim().split('\n')[0] || 'git checkout failed');
144
+ }
145
+ return out;
146
+ }
147
+ }
package/src/core/tests.js CHANGED
@@ -42,7 +42,11 @@ export async function testRunnerFor(dir) {
42
42
  ['pytest.ini', 'pyproject.toml', 'setup.cfg', 'tox.ini'].map((f) =>
43
43
  fs.access(path.join(dir, f)).then(() => true, () => false))
44
44
  );
45
- return py.some(Boolean) ? 'pytest' : null;
45
+ if (py.some(Boolean)) return 'pytest';
46
+ const at = (f) => fs.access(path.join(dir, f)).then(() => true, () => false);
47
+ if (await at('go.mod')) return 'go';
48
+ if (await at('Cargo.toml')) return 'cargo';
49
+ return null;
46
50
  }
47
51
 
48
52
  /** Quote a path for a shell, and use forward slashes so Windows agrees. */
@@ -71,6 +75,15 @@ export function relatedCommand(runner, files) {
71
75
  if (!tests.length) return null;
72
76
  return `python -m pytest -q ${tests.map(arg).join(' ')}`;
73
77
  }
78
+ // Go tests a package at a time: the packages that hold the changed files.
79
+ if (runner === 'go') {
80
+ const dirs = [...new Set(list.filter((f) => /\.go$/i.test(f)).map((f) => path.dirname(f).replace(/\\/g, '/')))];
81
+ if (!dirs.length) return null;
82
+ return `go test ${dirs.map((d) => `./${d === '.' ? '' : `${d}/`}...`).join(' ')}`;
83
+ }
84
+ if (runner === 'cargo') {
85
+ return list.some((f) => /\.rs$/i.test(f)) ? 'cargo test --quiet' : null;
86
+ }
74
87
  return null;
75
88
  }
76
89
 
@@ -154,7 +154,8 @@ async function review(shots) {
154
154
  // tokens came back as an empty review. Keep the thinking short, and leave
155
155
  // room for the answer.
156
156
  maxOutputTokens: 4000,
157
- reasoning: { effort: 'low' },
157
+ // Gemini's own field; the OpenRouter-style `reasoning` object was ignored.
158
+ effort: 'low',
158
159
  // The free vision model is often busy. One try, and a hard cap: a review
159
160
  // that cannot run is skipped, never waited on.
160
161
  attempts: 1,
@@ -3,6 +3,7 @@
3
3
  * name pattern, and which lines match a regular expression.
4
4
  */
5
5
 
6
+ import { spawn } from 'node:child_process';
6
7
  import { promises as fs } from 'node:fs';
7
8
  import path from 'node:path';
8
9
  import { ToolFailure } from '../core/failure.js';
@@ -11,6 +12,59 @@ import {
11
12
  SKIP, MAX_GLOB_HITS, MAX_GREP_HITS, WALK_WIDTH,
12
13
  } from './shared.js';
13
14
 
15
+ /** Whether `rg` runs here, asked once. UCODE_RIPGREP=0 never uses it. */
16
+ let hasRipgrep;
17
+ function ripgrepReady() {
18
+ if (process.env.UCODE_RIPGREP === '0') return Promise.resolve(false);
19
+ hasRipgrep ??= new Promise((resolve) => {
20
+ const child = spawn('rg', ['--version'], { windowsHide: true, stdio: 'ignore' });
21
+ child.on('error', () => resolve(false));
22
+ child.on('close', (code) => resolve(code === 0));
23
+ });
24
+ return hasRipgrep;
25
+ }
26
+
27
+ /**
28
+ * The same search through ripgrep, with the same rules as the walk below: the
29
+ * same folders skipped, hidden files and .gitignored ones searched, the same
30
+ * line format. Resolves to { hits, files }, or null when ripgrep is missing or
31
+ * cannot read the pattern.
32
+ */
33
+ async function ripgrep(pattern, dir, { filter, ignoreCase }) {
34
+ if (!(await ripgrepReady())) return null;
35
+ const args = ['--line-number', '--no-heading', '--color', 'never', '--hidden', '--no-ignore', '--sort', 'path'];
36
+ for (const skip of SKIP) args.push('-g', `!${skip}/`);
37
+ if (filter) args.push('-g', filter);
38
+ if (ignoreCase) args.push('-i');
39
+ args.push('-e', pattern, '--', '.');
40
+
41
+ return new Promise((resolve) => {
42
+ const hits = [];
43
+ const files = new Set();
44
+ let rest = '';
45
+ let done = false;
46
+ const child = spawn('rg', args, { cwd: dir, windowsHide: true });
47
+ const finish = (value) => { if (!done) { done = true; resolve(value); } };
48
+ child.on('error', () => finish(null));
49
+ child.stdout.setEncoding('utf8');
50
+ child.stdout.on('data', (chunk) => {
51
+ const lines = (rest + chunk).split(/\r?\n/);
52
+ rest = lines.pop();
53
+ for (const line of lines) {
54
+ const m = /^(.*?):(\d+):(.*)$/.exec(line);
55
+ if (!m || hits.length >= MAX_GREP_HITS) continue;
56
+ const rel = m[1].replace(/^\.[\\/]/, '').replace(/\\/g, '/');
57
+ const text = m[3].trim();
58
+ files.add(rel);
59
+ hits.push(`${rel}:${m[2]}: ${text.length > 200 ? `${text.slice(0, 200)}…` : text}`);
60
+ }
61
+ if (hits.length >= MAX_GREP_HITS) { child.kill(); finish({ hits, files: files.size }); }
62
+ });
63
+ // 0 found, 1 nothing found, 2 an error such as a pattern it cannot read.
64
+ child.on('close', (code) => finish(code === 0 || code === 1 || hits.length ? { hits, files: files.size } : null));
65
+ });
66
+ }
67
+
14
68
  export async function listDir({ path: p = '.' }) {
15
69
  const target = resolveIn(p || '.', 'list_dir');
16
70
  await guard(target, `list ${target.abs}`);
@@ -131,6 +185,26 @@ export async function grep({ pattern, path: p = '.', glob: filter, ignore_case =
131
185
  throw fsFailure(err, 'searching file contents', target.show);
132
186
  });
133
187
 
188
+ // A directory goes to ripgrep when it is installed: it is many times faster
189
+ // on a real project. A pattern ripgrep reads differently (lookarounds,
190
+ // back-references) makes it fail, and the search below runs instead.
191
+ if (!stat.isFile()) {
192
+ const fast = await ripgrep(pattern, target.abs, { filter, ignoreCase: ignore_case });
193
+ if (fast) {
194
+ if (!fast.hits.length) {
195
+ return result(`No line matched /${pattern}/ under ${target.show}${filter ? ` (limited to ${filter})` : ''}.`, 'no matches');
196
+ }
197
+ const capped = fast.hits.length >= MAX_GREP_HITS
198
+ ? `\n[stopped at ${MAX_GREP_HITS} matches — narrow the pattern, or pass a glob]`
199
+ : '';
200
+ return result(
201
+ fast.hits.join('\n') + capped,
202
+ `${fast.hits.length} match${fast.hits.length === 1 ? '' : 'es'} in ` +
203
+ `${fast.files} file${fast.files === 1 ? '' : 's'}`
204
+ );
205
+ }
206
+ }
207
+
134
208
  let base = target.abs;
135
209
  let candidates;
136
210
  if (stat.isFile()) {
@@ -10,6 +10,7 @@
10
10
  import { promises as fs } from 'node:fs';
11
11
  import path from 'node:path';
12
12
  import { ToolFailure, Declined } from '../core/failure.js';
13
+ import { isAllowed, commandKey } from '../core/settings.js';
13
14
 
14
15
  /**
15
16
  * How much of a result the model may see.
@@ -63,7 +64,25 @@ export function setConfirm(fn) {
63
64
  asker = fn;
64
65
  }
65
66
 
66
- export async function confirm(action, detail, risk = 'write') {
67
+ /**
68
+ * What may run without asking. `askCommands` is the "commands": "ask" setting;
69
+ * `allow` is the always-allow list; `onAlways` saves a new entry to it.
70
+ */
71
+ let policy = { askCommands: false, allow: [], onAlways: null };
72
+
73
+ export function setPolicy(next) {
74
+ policy = { ...policy, ...next };
75
+ }
76
+
77
+ /** Is this command, or "mcp:tool", on the always-allow list? */
78
+ export const allowedNow = (subject) => isAllowed(policy.allow, subject);
79
+
80
+ /**
81
+ * Ask before acting. `always` names what an "always" answer allows from then
82
+ * on: a command's first words, or "mcp:tool". Callers check allowedNow()
83
+ * against the whole command first — the entry is only what gets saved.
84
+ */
85
+ export async function confirm(action, detail, risk = 'write', always = null) {
67
86
  if (!asker) {
68
87
  throw new ToolFailure({
69
88
  kind: 'cannot_ask',
@@ -72,7 +91,19 @@ export async function confirm(action, detail, risk = 'write') {
72
91
  fix: 'Run ucode in a terminal so it can prompt before acting.',
73
92
  });
74
93
  }
75
- if (!(await asker({ action, detail, risk }))) throw new Declined(action);
94
+ const answer = await asker({ action, detail, risk, always });
95
+ if (answer === 'always' && always) {
96
+ policy = { ...policy, allow: [...policy.allow, always] };
97
+ await Promise.resolve(policy.onAlways?.(always)).catch(() => {});
98
+ return;
99
+ }
100
+ if (!answer) throw new Declined(action);
101
+ }
102
+
103
+ /** A command about to run, put to the user first when they asked for that. */
104
+ export async function approveCommand(command, where) {
105
+ if (!policy.askCommands || allowedNow(command)) return;
106
+ await confirm(`run ${String(command).trim().slice(0, 160)}`, `in ${where}`, 'command', commandKey(command));
76
107
  }
77
108
 
78
109
  // ---------------------------------------------------------------------------
@@ -14,7 +14,7 @@ import { openSync, closeSync, readFileSync, mkdirSync, statSync } from 'node:fs'
14
14
  import os from 'node:os';
15
15
  import path from 'node:path';
16
16
  import { ToolFailure } from '../core/failure.js';
17
- import { resolveIn, guard, confirm, result, getRoot, MAX_OUTPUT } from './shared.js';
17
+ import { resolveIn, guard, confirm, approveCommand, result, getRoot, MAX_OUTPUT } from './shared.js';
18
18
 
19
19
  const DEFAULT_TIMEOUT = 120_000;
20
20
  const MAX_TIMEOUT = 600_000;
@@ -713,6 +713,9 @@ export async function runCommand({ command, cwd, timeout_ms, background }, { onO
713
713
  : { abs: getRoot(), inside: true, show: '.' };
714
714
  await guard(workdir, `run a command in ${workdir.abs}`);
715
715
 
716
+ // "commands": "ask" in settings: every command is put to the user first.
717
+ await approveCommand(command, workdir.show);
718
+
716
719
  const env = childEnv();
717
720
  const server = startsServer(command);
718
721
 
package/src/ui/plain.js CHANGED
@@ -24,6 +24,9 @@ const COMMANDS = [
24
24
  '/new', '/remember', '/skills', '/clear', '/search', '/copy', '/exit',
25
25
  ];
26
26
 
27
+ /** The tag on an approval prompt. */
28
+ export const badge = (risk) => ({ command: ' shell ', mcp: ' tool ', trust: ' project ' })[risk] ?? ' outside project ';
29
+
27
30
  export class Plain {
28
31
  constructor({ cwd, input = process.stdin, output = process.stdout } = {}) {
29
32
  this.cwd = cwd;
@@ -293,18 +296,18 @@ export class Plain {
293
296
  return this.promptWith(`${blue('› ')}`);
294
297
  }
295
298
 
296
- async confirm({ action, detail, risk }) {
299
+ async confirm({ action, detail, risk, always }) {
297
300
  this.stopSpinner();
298
- const badge = risk === 'command' ? ' shell ' : ' outside project ';
299
- this.output.write(`\n${chalk.inverse(theme.warn(badge))} ${chalk.white(action)}\n`);
301
+ this.output.write(`\n${chalk.inverse(theme.warn(badge(risk)))} ${chalk.white(action)}\n`);
300
302
  for (const line of String(detail ?? '').split('\n')) {
301
303
  if (line) this.output.write(dim(` ${line}\n`));
302
304
  }
303
305
 
304
- const answer = await this.promptWith(`${blue(' go ahead? ')}${dim('[y/N] ')}`);
306
+ const answer = await this.promptWith(`${blue(' go ahead? ')}${dim(always ? `[y/N, a = always allow ${always}] ` : '[y/N] ')}`);
305
307
  // End of input is a no: never run something nobody approved.
306
- const yes = /^(y|yes)$/i.test(String(answer ?? '').trim());
307
- this.output.write(dim(yes ? ' approved\n\n' : ' declined\n\n'));
308
+ const said = String(answer ?? '').trim();
309
+ const yes = always && /^(a|always)$/i.test(said) ? 'always' : /^(y|yes)$/i.test(said);
310
+ this.output.write(dim(yes === 'always' ? ` approved — ${always} is always allowed here now\n\n` : yes ? ' approved\n\n' : ' declined\n\n'));
308
311
  return yes;
309
312
  }
310
313
 
package/src/ui/screen.js CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  import { FRAME_MS, spinnerGlyph, formatDuration, doneLine, workingLine, bannerSweep, SWEEP_MS } from './activity.js';
45
45
  import { gitBranch } from '../core/git.js';
46
46
  import { renderer, render } from './markdown.js';
47
+ import { badge } from './plain.js';
47
48
  import { VERSION } from '../core/version.js';
48
49
 
49
50
  /**
@@ -1203,22 +1204,23 @@ export class Screen {
1203
1204
  }
1204
1205
 
1205
1206
  /** y/n, answered on the input line. */
1206
- confirm({ action, detail, risk }) {
1207
+ confirm({ action, detail, risk, always }) {
1207
1208
  this.push('');
1208
- this.push(`${chalk.inverse(theme.warn(risk === 'command' ? ' shell ' : ' outside project '))} ${chalk.white(action)}`);
1209
+ this.push(`${chalk.inverse(theme.warn(badge(risk)))} ${chalk.white(action)}`);
1209
1210
  for (const line of String(detail ?? '').split('\n')) {
1210
1211
  if (line) this.push(dim(` ${line}`));
1211
1212
  }
1212
1213
 
1213
- this.pendingPrompt = 'go ahead? [y/N]';
1214
+ this.pendingPrompt = always ? `go ahead? [y/N, a = always allow ${always}]` : 'go ahead? [y/N]';
1214
1215
  this.scroll = 0; // the question has to be on screen to be answered
1215
1216
  this.render();
1216
1217
 
1217
1218
  return this.nextLine().then((answer) => {
1218
1219
  this.pendingPrompt = null;
1219
1220
  // End of input counts as no. Never run something nobody approved.
1220
- const yes = /^(y|yes)$/i.test(String(answer ?? '').trim());
1221
- this.push(dim(yes ? ' approved' : ' declined'));
1221
+ const said = String(answer ?? '').trim();
1222
+ const yes = always && /^(a|always)$/i.test(said) ? 'always' : /^(y|yes)$/i.test(said);
1223
+ this.push(dim(yes === 'always' ? ` approved — ${always} is always allowed here now` : yes ? ' approved' : ' declined'));
1222
1224
  this.push('');
1223
1225
  return yes;
1224
1226
  });
package/src/ui/theme.js CHANGED
@@ -794,8 +794,30 @@ const CAVEAT = /\b(?:however|failed|couldn't|could not|cannot|can't|didn't|did n
794
794
  const ACTIONABLE = /\b(?:open|run|serve|visit|try|start|npm|npx|node|pnpm|yarn)\b|https?:\/\/|\.(?:html?|css|jsx?|tsx?|md|json|py|rs|go)\b/i;
795
795
  const BULLET = /^\s*(?:[-*•>]|\d+[.)]|[✓✔✅☑])\s+/;
796
796
 
797
+ /**
798
+ * Words that say nothing about this app, because they are said about every
799
+ * app: "a sleek, modern and intuitive interface for a seamless experience".
800
+ */
801
+ export const STOCK = /\b(?:seamless(?:ly)?|sleek|modern and clean|clean and modern|robust|intuitive|user-friendly|cutting-edge|state-of-the-art|elevate[sd]?|effortless(?:ly)?|stunning|delightful|vibrant|happy coding|feel free to|i hope (?:this|you)|let me know if)\b/i;
802
+
803
+ /**
804
+ * The closing message without its stock sentences. A sentence goes only when
805
+ * it is pure filler: one that says how to try the app, or admits something is
806
+ * unfinished, stays whatever words it uses. Never returns nothing.
807
+ */
808
+ export function withoutStock(text) {
809
+ const lines = String(text ?? '').replace(/\r/g, '').split('\n');
810
+ const out = [];
811
+ for (const line of lines) {
812
+ if (!STOCK.test(line)) { out.push(line); continue; }
813
+ const kept = line.split(/(?<=[.!?])\s+/).filter((s) => !STOCK.test(s) || ACTIONABLE.test(s) || CAVEAT.test(s));
814
+ if (kept.length) out.push(kept.join(' '));
815
+ }
816
+ return out.join('\n').trim() || lines.join('\n').trim();
817
+ }
818
+
797
819
  export function trimAnswer(text, max = ANSWER_LINES) {
798
- const all = String(text ?? '').replace(/\r/g, '').split('\n');
820
+ const all = withoutStock(text).split('\n');
799
821
 
800
822
  let start = 0;
801
823
  while (start < all.length && (!all[start].trim() || RESTATED.test(all[start].trim()))) start++;