ucode-agent 1.63.0 → 1.65.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 +157 -8
- 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 +611 -20
- 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/terminal.js +232 -0
- package/src/core/tests.js +14 -1
- package/src/tools/browser.js +2 -1
- package/src/tools/index.js +4 -0
- package/src/tools/search.js +74 -0
- package/src/tools/shared.js +33 -2
- package/src/tools/shell.js +4 -1
- package/src/ui/activity.js +2 -2
- package/src/ui/plain.js +11 -8
- package/src/ui/screen.js +9 -7
- package/src/ui/theme.js +163 -12
- 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
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* terminal.js — changing the terminal ucode runs in, when the user asks:
|
|
3
|
+
* "make my terminal dark green", "bigger font", "a bit see-through".
|
|
4
|
+
*
|
|
5
|
+
* Each terminal keeps its look somewhere different, so each gets its own way:
|
|
6
|
+
*
|
|
7
|
+
* Windows Terminal its settings.json, which it reloads the moment it is saved.
|
|
8
|
+
* Kept: every new tab looks the same. The file as it was is
|
|
9
|
+
* backed up first, and `reset` puts it back.
|
|
10
|
+
* Terminal.app AppleScript on the window ucode is in: colours, cursor,
|
|
11
|
+
* font and size. That window only; the old values are kept
|
|
12
|
+
* so `reset` can restore them.
|
|
13
|
+
* iTerm2 its escape sequence for colours, this session only.
|
|
14
|
+
* anything else the standard xterm escapes for colours, this session only.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here runs unless the user asked, and the loop asks before it does.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { execFile } from 'node:child_process';
|
|
20
|
+
import { promises as fs, existsSync } from 'node:fs';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { jsonrepair } from 'jsonrepair';
|
|
24
|
+
import { normaliseColour, hexRGB } from '../ui/theme.js';
|
|
25
|
+
|
|
26
|
+
export const BACKUP_DIR = path.join(os.homedir(), '.ucode', 'terminal-backup');
|
|
27
|
+
const backups = () => process.env.UCODE_TERMINAL_BACKUP || BACKUP_DIR;
|
|
28
|
+
|
|
29
|
+
/** Which terminal this is, from what it puts in the environment. */
|
|
30
|
+
export function detectTerminal(env = process.env, platform = process.platform) {
|
|
31
|
+
if (env.WT_SESSION) return 'windows-terminal';
|
|
32
|
+
if (env.TERM_PROGRAM === 'Apple_Terminal') return 'apple-terminal';
|
|
33
|
+
if (env.TERM_PROGRAM === 'iTerm.app' || env.LC_TERMINAL === 'iTerm2') return 'iterm';
|
|
34
|
+
if (env.TERM_PROGRAM === 'vscode') return 'vscode';
|
|
35
|
+
if (platform === 'win32') return 'windows-console';
|
|
36
|
+
return 'other';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const TERMINAL_NAMES = {
|
|
40
|
+
'windows-terminal': 'Windows Terminal',
|
|
41
|
+
'apple-terminal': 'Terminal',
|
|
42
|
+
iterm: 'iTerm2',
|
|
43
|
+
vscode: 'the VS Code terminal',
|
|
44
|
+
'windows-console': 'the Windows console',
|
|
45
|
+
other: 'this terminal',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Where Windows Terminal keeps its settings: the Store build, Preview, or unpackaged. */
|
|
49
|
+
export function windowsTerminalSettings(env = process.env) {
|
|
50
|
+
const local = env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local');
|
|
51
|
+
const candidates = [
|
|
52
|
+
path.join(local, 'Packages', 'Microsoft.WindowsTerminal_8wekyb3d8bbwe', 'LocalState', 'settings.json'),
|
|
53
|
+
path.join(local, 'Packages', 'Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe', 'LocalState', 'settings.json'),
|
|
54
|
+
path.join(local, 'Microsoft', 'Windows Terminal', 'settings.json'),
|
|
55
|
+
];
|
|
56
|
+
return candidates.find((f) => existsSync(f)) ?? null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What was asked for, cleaned: colours as #rrggbb, a sane size and opacity. */
|
|
60
|
+
export function cleanRequest(req = {}) {
|
|
61
|
+
const out = {};
|
|
62
|
+
for (const key of ['background', 'foreground', 'cursor']) {
|
|
63
|
+
if (req[key] === undefined || req[key] === null || req[key] === '') continue;
|
|
64
|
+
const colour = normaliseColour(req[key]);
|
|
65
|
+
if (!colour) throw new Error(`"${req[key]}" is not a colour ucode can read — use a name like "navy" or a hex like #1e1e2e`);
|
|
66
|
+
out[key] = colour;
|
|
67
|
+
}
|
|
68
|
+
if (req.font_size !== undefined && req.font_size !== null && req.font_size !== '') {
|
|
69
|
+
const size = Number(req.font_size);
|
|
70
|
+
if (!Number.isFinite(size) || size < 6 || size > 48) throw new Error('font_size should be a number from 6 to 48');
|
|
71
|
+
out.font_size = Math.round(size);
|
|
72
|
+
}
|
|
73
|
+
if (req.font) out.font = String(req.font).replace(/["\\\r\n]/g, '').slice(0, 60);
|
|
74
|
+
if (req.opacity !== undefined && req.opacity !== null && req.opacity !== '') {
|
|
75
|
+
const opacity = Number(req.opacity);
|
|
76
|
+
if (!Number.isFinite(opacity) || opacity < 20 || opacity > 100) throw new Error('opacity should be a number from 20 to 100');
|
|
77
|
+
out.opacity = Math.round(opacity);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Windows Terminal: set the look on the defaults, and on any profile that overrides it. */
|
|
83
|
+
export function applyToWindowsTerminal(settings, want) {
|
|
84
|
+
const next = structuredClone(settings);
|
|
85
|
+
if (Array.isArray(next.profiles)) next.profiles = { defaults: {}, list: next.profiles };
|
|
86
|
+
next.profiles ??= {};
|
|
87
|
+
next.profiles.defaults ??= {};
|
|
88
|
+
const set = (profile) => {
|
|
89
|
+
if (want.background) profile.background = want.background;
|
|
90
|
+
if (want.foreground) profile.foreground = want.foreground;
|
|
91
|
+
if (want.cursor) profile.cursorColor = want.cursor;
|
|
92
|
+
if (want.font || want.font_size) {
|
|
93
|
+
profile.font = { ...(profile.font ?? {}), ...(want.font ? { face: want.font } : {}), ...(want.font_size ? { size: want.font_size } : {}) };
|
|
94
|
+
}
|
|
95
|
+
if (want.opacity !== undefined) {
|
|
96
|
+
profile.opacity = want.opacity;
|
|
97
|
+
profile.useAcrylic = want.opacity < 100;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
set(next.profiles.defaults);
|
|
101
|
+
// A profile with its own value would hide the default, so it changes too.
|
|
102
|
+
const keys = ['background', 'foreground', 'cursorColor', 'font', 'opacity'];
|
|
103
|
+
for (const profile of next.profiles.list ?? []) {
|
|
104
|
+
if (keys.some((k) => profile[k] !== undefined)) set(profile);
|
|
105
|
+
}
|
|
106
|
+
return next;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const run = (file, args) => new Promise((resolve) => {
|
|
110
|
+
execFile(file, args, { windowsHide: true, timeout: 15_000 }, (err, stdout, stderr) =>
|
|
111
|
+
resolve({ ok: !err, out: String(stdout ?? '').trim(), err: String(stderr || err?.message || '').trim() }));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/** AppleScript's colours run 0-65535. */
|
|
115
|
+
const appleColour = (hex) => `{${hexRGB(hex).map((v) => v * 257).join(', ')}}`;
|
|
116
|
+
const TAB = 'selected tab of front window';
|
|
117
|
+
|
|
118
|
+
async function appleTerminal(want) {
|
|
119
|
+
const backup = path.join(backups(), 'apple-terminal.json');
|
|
120
|
+
if (!existsSync(backup)) {
|
|
121
|
+
const get = await run('osascript', ['-e', `tell application "Terminal" to get {background color, normal text color, cursor color, font name, font size} of ${TAB}`]);
|
|
122
|
+
if (get.ok) {
|
|
123
|
+
await fs.mkdir(backups(), { recursive: true });
|
|
124
|
+
await fs.writeFile(backup, JSON.stringify({ raw: get.out }));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const lines = [];
|
|
128
|
+
if (want.background) lines.push(`set background color of ${TAB} to ${appleColour(want.background)}`);
|
|
129
|
+
if (want.foreground) lines.push(`set normal text color of ${TAB} to ${appleColour(want.foreground)}`);
|
|
130
|
+
if (want.cursor) lines.push(`set cursor color of ${TAB} to ${appleColour(want.cursor)}`);
|
|
131
|
+
if (want.font) lines.push(`set font name of ${TAB} to "${want.font}"`);
|
|
132
|
+
if (want.font_size) lines.push(`set font size of ${TAB} to ${want.font_size}`);
|
|
133
|
+
if (!lines.length) return { ok: false, message: 'Terminal can change colours, the cursor, the font and its size here — not the opacity.' };
|
|
134
|
+
const script = ['tell application "Terminal"', ...lines.map((l) => ` ${l}`), 'end tell'].join('\n');
|
|
135
|
+
const done = await run('osascript', ['-e', script]);
|
|
136
|
+
return done.ok
|
|
137
|
+
? { ok: true, message: `Changed this Terminal window${want.opacity !== undefined ? ' (opacity is not something Terminal lets ucode set)' : ''}.` }
|
|
138
|
+
: { ok: false, message: `Terminal refused: ${done.err}. macOS may need ucode's terminal allowed under Privacy & Security > Automation.` };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The escape sequences for colours: iTerm2's own, or the xterm ones nearly everything reads. */
|
|
142
|
+
export function colourEscapes(kind, want) {
|
|
143
|
+
const hex = (h) => h.slice(1);
|
|
144
|
+
if (kind === 'iterm') {
|
|
145
|
+
return [
|
|
146
|
+
want.background && `\x1b]1337;SetColors=bg=${hex(want.background)}\x07`,
|
|
147
|
+
want.foreground && `\x1b]1337;SetColors=fg=${hex(want.foreground)}\x07`,
|
|
148
|
+
want.cursor && `\x1b]1337;SetColors=curbg=${hex(want.cursor)}\x07`,
|
|
149
|
+
].filter(Boolean).join('');
|
|
150
|
+
}
|
|
151
|
+
return [
|
|
152
|
+
want.foreground && `\x1b]10;${want.foreground}\x07`,
|
|
153
|
+
want.background && `\x1b]11;${want.background}\x07`,
|
|
154
|
+
want.cursor && `\x1b]12;${want.cursor}\x07`,
|
|
155
|
+
].filter(Boolean).join('');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Change the terminal. Resolves to { ok, message } and never throws on a
|
|
160
|
+
* terminal that cannot do it; throws only on a request that makes no sense.
|
|
161
|
+
*/
|
|
162
|
+
export async function customizeTerminal(request, { env = process.env, platform = process.platform, write = (s) => process.stdout.write(s) } = {}) {
|
|
163
|
+
const want = cleanRequest(request);
|
|
164
|
+
if (!Object.keys(want).length) throw new Error('Say what to change: background, foreground, cursor, font, font_size or opacity.');
|
|
165
|
+
const kind = detectTerminal(env, platform);
|
|
166
|
+
|
|
167
|
+
if (kind === 'windows-terminal') {
|
|
168
|
+
const file = windowsTerminalSettings(env);
|
|
169
|
+
if (!file) return { ok: false, message: 'Could not find Windows Terminal\'s settings.json.' };
|
|
170
|
+
const text = await fs.readFile(file, 'utf8');
|
|
171
|
+
if (!text.trim()) return { ok: false, message: 'Windows Terminal\'s settings.json is empty, so it was left alone.' };
|
|
172
|
+
let settings;
|
|
173
|
+
try {
|
|
174
|
+
settings = JSON.parse(jsonrepair(text.replace(/^/, '')));
|
|
175
|
+
} catch {
|
|
176
|
+
return { ok: false, message: 'Windows Terminal\'s settings.json could not be read, so it was left alone.' };
|
|
177
|
+
}
|
|
178
|
+
await fs.mkdir(backups(), { recursive: true });
|
|
179
|
+
const backup = path.join(backups(), 'windows-terminal.json');
|
|
180
|
+
if (!existsSync(backup)) await fs.writeFile(backup, JSON.stringify({ file, text }));
|
|
181
|
+
await fs.writeFile(file, `${JSON.stringify(applyToWindowsTerminal(settings, want), null, 4)}\n`);
|
|
182
|
+
return { ok: true, message: 'Changed Windows Terminal — every tab, and it stays. "/theme terminal reset" puts it back.' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (kind === 'apple-terminal') return appleTerminal(want);
|
|
186
|
+
|
|
187
|
+
if (kind === 'windows-console') {
|
|
188
|
+
return { ok: false, message: 'The old Windows console cannot be changed from here. Windows Terminal (free in the Microsoft Store) can.' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const escapes = colourEscapes(kind, want);
|
|
192
|
+
if (!escapes) return { ok: false, message: `${TERMINAL_NAMES[kind]} only lets ucode change its colours, not the font or opacity.` };
|
|
193
|
+
write(escapes);
|
|
194
|
+
const skipped = want.font || want.font_size || want.opacity !== undefined ? ' The font and opacity are not something it lets ucode set.' : '';
|
|
195
|
+
return { ok: true, message: `Changed ${TERMINAL_NAMES[kind]}'s colours for this session.${skipped}` };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Put the terminal back as it was before ucode changed it. */
|
|
199
|
+
export async function resetTerminal({ env = process.env, platform = process.platform, write = (s) => process.stdout.write(s) } = {}) {
|
|
200
|
+
const kind = detectTerminal(env, platform);
|
|
201
|
+
if (kind === 'windows-terminal') {
|
|
202
|
+
const backup = path.join(backups(), 'windows-terminal.json');
|
|
203
|
+
if (!existsSync(backup)) return { ok: false, message: 'ucode has not changed Windows Terminal, so there is nothing to put back.' };
|
|
204
|
+
const { file, text } = JSON.parse(await fs.readFile(backup, 'utf8'));
|
|
205
|
+
await fs.writeFile(file, text);
|
|
206
|
+
await fs.rm(backup, { force: true });
|
|
207
|
+
return { ok: true, message: 'Windows Terminal is back the way it was.' };
|
|
208
|
+
}
|
|
209
|
+
if (kind === 'apple-terminal') {
|
|
210
|
+
const backup = path.join(backups(), 'apple-terminal.json');
|
|
211
|
+
if (!existsSync(backup)) return { ok: false, message: 'ucode has not changed this Terminal window.' };
|
|
212
|
+
const { raw } = JSON.parse(await fs.readFile(backup, 'utf8'));
|
|
213
|
+
const n = raw.split(/,\s*/);
|
|
214
|
+
// {r,g,b} three times, then the font name and size.
|
|
215
|
+
if (n.length >= 11) {
|
|
216
|
+
const colour = (i) => `{${n.slice(i, i + 3).join(', ')}}`;
|
|
217
|
+
await run('osascript', ['-e', [
|
|
218
|
+
'tell application "Terminal"',
|
|
219
|
+
` set background color of ${TAB} to ${colour(0)}`,
|
|
220
|
+
` set normal text color of ${TAB} to ${colour(3)}`,
|
|
221
|
+
` set cursor color of ${TAB} to ${colour(6)}`,
|
|
222
|
+
` set font name of ${TAB} to "${n[9].replace(/"/g, '')}"`,
|
|
223
|
+
` set font size of ${TAB} to ${Number(n[10]) || 12}`,
|
|
224
|
+
'end tell',
|
|
225
|
+
].join('\n')]);
|
|
226
|
+
}
|
|
227
|
+
await fs.rm(backup, { force: true });
|
|
228
|
+
return { ok: true, message: 'This Terminal window is back the way it was.' };
|
|
229
|
+
}
|
|
230
|
+
write(kind === 'iterm' ? '\x1b]1337;SetColors=preset=Default\x07' : '\x1b]110\x07\x1b]111\x07\x1b]112\x07');
|
|
231
|
+
return { ok: true, message: 'The terminal\'s own colours are back.' };
|
|
232
|
+
}
|
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/index.js
CHANGED
|
@@ -722,6 +722,10 @@ export function describe(name, args = {}) {
|
|
|
722
722
|
return `Searching the web for ${clip(args.query, 60)}`;
|
|
723
723
|
case 'load_skill':
|
|
724
724
|
return `Loading the ${clip(args.name, 40)} skill`;
|
|
725
|
+
case 'change_look':
|
|
726
|
+
return args.reset ? 'Putting ucode\'s look back' : 'Changing how ucode looks';
|
|
727
|
+
case 'change_terminal':
|
|
728
|
+
return args.reset ? 'Putting the terminal back' : 'Changing the terminal';
|
|
725
729
|
default:
|
|
726
730
|
return `${name} ${clip(JSON.stringify(args), 60)}`;
|
|
727
731
|
}
|
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()) {
|