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.
- package/README.md +95 -2
- package/package.json +65 -64
- package/src/core/commands.js +44 -0
- package/src/core/genericcheck.js +147 -0
- package/src/core/headless.js +73 -0
- package/src/core/lessons.js +83 -0
- package/src/core/loop.js +453 -18
- package/src/core/mcp.js +337 -0
- package/src/core/mcpcli.js +52 -0
- package/src/core/provider.js +93 -11
- package/src/core/scope.js +62 -3
- package/src/core/settings.js +148 -0
- package/src/core/snapshot.js +147 -0
- package/src/core/tests.js +14 -1
- package/src/tools/browser.js +2 -1
- package/src/tools/search.js +74 -0
- package/src/tools/shared.js +33 -2
- package/src/tools/shell.js +4 -1
- package/src/ui/plain.js +9 -6
- package/src/ui/screen.js +7 -5
- package/src/ui/theme.js +23 -1
- package/ucode.js +37 -1
|
@@ -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
|
-
|
|
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
|
|
package/src/tools/browser.js
CHANGED
|
@@ -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
|
|
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,
|
package/src/tools/search.js
CHANGED
|
@@ -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()) {
|
package/src/tools/shared.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
package/src/tools/shell.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
307
|
-
|
|
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
|
|
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
|
|
1221
|
-
|
|
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 =
|
|
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++;
|