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
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/activity.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import chalk, { Chalk } from 'chalk';
|
|
15
|
-
import { dim, sky, theme, clip, SPINNER, bannerRGB, bannerPaint } from './theme.js';
|
|
15
|
+
import { dim, sky, theme, clip, SPINNER, bannerRGB, bannerPaint, look, hexRGB } from './theme.js';
|
|
16
16
|
|
|
17
17
|
/** One painter per colour level, so a test can ask for truecolour on a pipe. */
|
|
18
18
|
const painters = new Map();
|
|
@@ -289,7 +289,7 @@ export function spinnerGlyph(frame, t, { level = chalk.level } = {}) {
|
|
|
289
289
|
const glyph = SPINNER[((frame % SPINNER.length) + SPINNER.length) % SPINNER.length];
|
|
290
290
|
if (level < 2) return theme.blue(glyph);
|
|
291
291
|
const k = (Math.sin((Math.max(0, t) / 1000) * Math.PI * 2) + 1) / 2;
|
|
292
|
-
const [r, g, b] = mix(
|
|
292
|
+
const [r, g, b] = mix(hexRGB(look.deep), hexRGB(look.light), k);
|
|
293
293
|
return painter(level).rgb(r, g, b)(glyph);
|
|
294
294
|
}
|
|
295
295
|
|
package/src/ui/plain.js
CHANGED
|
@@ -14,7 +14,7 @@ import chalk from 'chalk';
|
|
|
14
14
|
import {
|
|
15
15
|
theme, blue, sky, dim, boxTop, boxBottom, boxRow,
|
|
16
16
|
BANNER, BANNER_WIDTH, SPINNER, clip, shortenPath, asLabel, padVis, visLen, planLine, bannerPaint, modeChip, narrationMark, groupKind,
|
|
17
|
-
tidyReply, trimAnswer,
|
|
17
|
+
tidyReply, trimAnswer, look,
|
|
18
18
|
} from './theme.js';
|
|
19
19
|
import { formatDuration, doneLine } from './activity.js';
|
|
20
20
|
import { renderer, render } from './markdown.js';
|
|
@@ -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;
|
|
@@ -98,7 +101,7 @@ export class Plain {
|
|
|
98
101
|
['keys', '/help'],
|
|
99
102
|
['', ''],
|
|
100
103
|
['', ''],
|
|
101
|
-
['',
|
|
104
|
+
['', look.byline],
|
|
102
105
|
];
|
|
103
106
|
|
|
104
107
|
// There is no input box to hang the status off here, so it goes on the
|
|
@@ -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
|
@@ -37,13 +37,14 @@ import { homedir } from 'node:os';
|
|
|
37
37
|
import path from 'node:path';
|
|
38
38
|
import chalk from 'chalk';
|
|
39
39
|
import {
|
|
40
|
-
theme, blue, sky, deep, dim, edge, ADDED, REMOVED, BANNER, BANNER_WIDTH, SPINNER,
|
|
40
|
+
theme, blue, sky, deep, dim, edge, ADDED, REMOVED, BANNER, BANNER_WIDTH, SPINNER, look,
|
|
41
41
|
boxTop, boxBottom, boxRow, visLen, padVis, clip, wrapAnsi,
|
|
42
42
|
shortenPath, asLabel, ensureColour, planLine, bare, narration, narrationMark, groupKind, groupLabel, groupTarget, runLine, planRows, tidyReply, trimAnswer,
|
|
43
43
|
bannerPaint, RAIL, modeChip, ADD_CHIP, micChip, asNarrationLine } from './theme.js';
|
|
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
|
/**
|
|
@@ -818,7 +819,7 @@ export class Screen {
|
|
|
818
819
|
].filter(Boolean).slice(0, BANNER.length - 1);
|
|
819
820
|
|
|
820
821
|
while (facts.length < BANNER.length - 1) facts.push(['', '']);
|
|
821
|
-
facts.push(['',
|
|
822
|
+
facts.push(['', look.byline]);
|
|
822
823
|
|
|
823
824
|
const rows = BANNER.map((art, i) => {
|
|
824
825
|
const [label, text] = facts[i] ?? ['', ''];
|
|
@@ -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
|
@@ -9,12 +9,17 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import chalk from 'chalk';
|
|
12
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
12
15
|
|
|
13
16
|
// One hue, three weights. Anything that needs a fourth is asking for emphasis
|
|
14
|
-
// it has not earned.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
export
|
|
17
|
+
// it has not earned. The hue is the user's to change ("make ucode orange"):
|
|
18
|
+
// these are `let`, so every module that imported them sees a new look the
|
|
19
|
+
// moment applyLook() runs - see the "Your look" section below.
|
|
20
|
+
export let blue = chalk.hex('#4d8dff'); // structure: borders, the caret, the wordmark
|
|
21
|
+
export let sky = chalk.hex('#8fbcff'); // secondary: labels that still matter
|
|
22
|
+
export let deep = chalk.hex('#2f6fe0'); // pressed, quiet, behind
|
|
18
23
|
export const dim = chalk.dim;
|
|
19
24
|
|
|
20
25
|
/**
|
|
@@ -25,7 +30,7 @@ export const dim = chalk.dim;
|
|
|
25
30
|
* terminal fonts visibly thicker, which is enough to separate "where you type"
|
|
26
31
|
* from "what you are reading" without a second colour.
|
|
27
32
|
*/
|
|
28
|
-
export
|
|
33
|
+
export let edge = chalk.hex('#4d8dff').bold;
|
|
29
34
|
|
|
30
35
|
/**
|
|
31
36
|
* The colour level to use for a stream, or null to leave chalk's guess alone.
|
|
@@ -87,8 +92,8 @@ export const BANNER_WIDTH = Math.max(...BANNER.map((r) => r.length));
|
|
|
87
92
|
* the page. chalk downshifts the hex to whatever the terminal actually has, so
|
|
88
93
|
* on a 16-colour terminal this is flat blue again rather than nothing.
|
|
89
94
|
*/
|
|
90
|
-
|
|
91
|
-
|
|
95
|
+
let GRADIENT_TOP = [0x8f, 0xbc, 0xff]; // sky, at the crown
|
|
96
|
+
let GRADIENT_BOTTOM = [0x2f, 0x6f, 0xe0]; // deep, in the shadow
|
|
92
97
|
|
|
93
98
|
export function bannerRGB(row, rows = BANNER.length) {
|
|
94
99
|
const t = rows > 1 ? Math.min(1, Math.max(0, row / (rows - 1))) : 0;
|
|
@@ -121,14 +126,14 @@ export const RAIL = '▌';
|
|
|
121
126
|
* once: it is the width of the word, the way a diff's tint is the width of the
|
|
122
127
|
* line it marks.
|
|
123
128
|
*/
|
|
124
|
-
export
|
|
125
|
-
export
|
|
129
|
+
export let BUILD_CHIP = chalk.bgHex('#4d8dff').hex('#0b1220').bold;
|
|
130
|
+
export let PLAN_CHIP = chalk.bgHex('#24344f').hex('#8fbcff').bold;
|
|
126
131
|
|
|
127
132
|
export const modeChip = (mode) =>
|
|
128
133
|
mode === 'plan' ? PLAN_CHIP(' PLAN ') : BUILD_CHIP(' BUILD ');
|
|
129
134
|
|
|
130
135
|
/** The "+ file" button beside the mode chip, in the quieter of the two chip styles. */
|
|
131
|
-
export
|
|
136
|
+
export let ADD_CHIP = PLAN_CHIP(' + file ');
|
|
132
137
|
|
|
133
138
|
/** The mic button beside it: quiet when idle, red while it is listening. */
|
|
134
139
|
const LISTEN_CHIP = chalk.bgHex('#e5484d').hex('#ffffff').bold;
|
|
@@ -138,7 +143,7 @@ export const micChip = (state) =>
|
|
|
138
143
|
: PLAN_CHIP(' mic ');
|
|
139
144
|
|
|
140
145
|
/** The spinner. Braille dots, because they animate in place without jitter. */
|
|
141
|
-
export
|
|
146
|
+
export let SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
142
147
|
|
|
143
148
|
// ---------------------------------------------------------------------------
|
|
144
149
|
// Boxes
|
|
@@ -794,8 +799,30 @@ const CAVEAT = /\b(?:however|failed|couldn't|could not|cannot|can't|didn't|did n
|
|
|
794
799
|
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
800
|
const BULLET = /^\s*(?:[-*•>]|\d+[.)]|[✓✔✅☑])\s+/;
|
|
796
801
|
|
|
802
|
+
/**
|
|
803
|
+
* Words that say nothing about this app, because they are said about every
|
|
804
|
+
* app: "a sleek, modern and intuitive interface for a seamless experience".
|
|
805
|
+
*/
|
|
806
|
+
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;
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* The closing message without its stock sentences. A sentence goes only when
|
|
810
|
+
* it is pure filler: one that says how to try the app, or admits something is
|
|
811
|
+
* unfinished, stays whatever words it uses. Never returns nothing.
|
|
812
|
+
*/
|
|
813
|
+
export function withoutStock(text) {
|
|
814
|
+
const lines = String(text ?? '').replace(/\r/g, '').split('\n');
|
|
815
|
+
const out = [];
|
|
816
|
+
for (const line of lines) {
|
|
817
|
+
if (!STOCK.test(line)) { out.push(line); continue; }
|
|
818
|
+
const kept = line.split(/(?<=[.!?])\s+/).filter((s) => !STOCK.test(s) || ACTIONABLE.test(s) || CAVEAT.test(s));
|
|
819
|
+
if (kept.length) out.push(kept.join(' '));
|
|
820
|
+
}
|
|
821
|
+
return out.join('\n').trim() || lines.join('\n').trim();
|
|
822
|
+
}
|
|
823
|
+
|
|
797
824
|
export function trimAnswer(text, max = ANSWER_LINES) {
|
|
798
|
-
const all =
|
|
825
|
+
const all = withoutStock(text).split('\n');
|
|
799
826
|
|
|
800
827
|
let start = 0;
|
|
801
828
|
while (start < all.length && (!all[start].trim() || RESTATED.test(all[start].trim()))) start++;
|
|
@@ -848,3 +875,127 @@ function withinRoom(text, room = ANSWER_ROOM) {
|
|
|
848
875
|
}
|
|
849
876
|
return (out.trim() || text.slice(0, room)).trim();
|
|
850
877
|
}
|
|
878
|
+
|
|
879
|
+
// ---------------------------------------------------------------------------
|
|
880
|
+
// Your look
|
|
881
|
+
// ---------------------------------------------------------------------------
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* ucode's look is the user's to change, by asking: "make yourself orange",
|
|
885
|
+
* "a calmer spinner", "put my name under the logo". It is a small file of
|
|
886
|
+
* choices rather than an edit to this one, so it survives every update and can
|
|
887
|
+
* never break ucode - a value that does not parse is simply ignored.
|
|
888
|
+
*
|
|
889
|
+
* { "accent": "#ff8c2b", "light": "#ffb454", "deep": "#c2410c",
|
|
890
|
+
* "spinner": "dots", "byline": "made by me" }
|
|
891
|
+
*
|
|
892
|
+
* Only accent is needed; light and deep are worked out from it.
|
|
893
|
+
*/
|
|
894
|
+
export const LOOK_FILE = process.env.UCODE_LOOK_FILE || path.join(os.homedir(), '.ucode', 'theme.json');
|
|
895
|
+
|
|
896
|
+
export const SPINNERS = {
|
|
897
|
+
dots: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
|
|
898
|
+
line: ['-', '\\', '|', '/'],
|
|
899
|
+
arc: ['◜', '◠', '◝', '◞', '◡', '◟'],
|
|
900
|
+
circle: ['◐', '◓', '◑', '◒'],
|
|
901
|
+
square: ['◰', '◳', '◲', '◱'],
|
|
902
|
+
bounce: ['⠁', '⠂', '⠄', '⠂'],
|
|
903
|
+
pulse: ['·', '•', '●', '•'],
|
|
904
|
+
star: ['✶', '✸', '✹', '✺', '✹', '✸'],
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
export const DEFAULT_LOOK = Object.freeze({
|
|
908
|
+
accent: '#4d8dff', light: '#8fbcff', deep: '#2f6fe0', spinner: 'dots', byline: 'made with ❤ by om dixit',
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
const NAMED = {
|
|
912
|
+
red: '#ef4444', orange: '#f97316', amber: '#f59e0b', gold: '#eab308', yellow: '#facc15', lime: '#84cc16',
|
|
913
|
+
green: '#22c55e', emerald: '#10b981', teal: '#14b8a6', cyan: '#06b6d4', sky: '#0ea5e9', blue: '#4d8dff',
|
|
914
|
+
indigo: '#6366f1', violet: '#8b5cf6', purple: '#a855f7', magenta: '#d946ef', pink: '#ec4899', rose: '#f43f5e',
|
|
915
|
+
white: '#f5f5f5', grey: '#9ca3af', gray: '#9ca3af', silver: '#c0c0c0', black: '#111111', brown: '#a16207',
|
|
916
|
+
coral: '#ff7f50', salmon: '#fa8072', navy: '#1e3a8a', maroon: '#9f1239', olive: '#65a30d', mint: '#6ee7b7',
|
|
917
|
+
lavender: '#c4b5fd', peach: '#fdba74', crimson: '#dc143c', turquoise: '#40e0d0',
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
/** '#abc', '#aabbcc', 'rgb(1,2,3)' or a colour name, as '#rrggbb' - or null. */
|
|
921
|
+
export function normaliseColour(value) {
|
|
922
|
+
const v = String(value ?? '').trim().toLowerCase();
|
|
923
|
+
if (NAMED[v]) return NAMED[v];
|
|
924
|
+
const short = /^#?([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(v);
|
|
925
|
+
if (short) return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`;
|
|
926
|
+
const long = /^#?([0-9a-f]{6})$/.exec(v);
|
|
927
|
+
if (long) return `#${long[1]}`;
|
|
928
|
+
const rgb = /^rgb\(\s*(\d{1,3})[\s,]+(\d{1,3})[\s,]+(\d{1,3})\s*\)$/.exec(v);
|
|
929
|
+
if (rgb && rgb.slice(1).every((n) => Number(n) <= 255)) {
|
|
930
|
+
return `#${rgb.slice(1).map((n) => Number(n).toString(16).padStart(2, '0')).join('')}`;
|
|
931
|
+
}
|
|
932
|
+
return null;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
export const hexRGB = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
|
936
|
+
const rgbHex = (rgb) => `#${rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('')}`;
|
|
937
|
+
|
|
938
|
+
/** Part of the way from one colour to another: t = 0 is a, 1 is b. */
|
|
939
|
+
export const mixHex = (a, b, t) => rgbHex(hexRGB(a).map((v, i) => v + (hexRGB(b)[i] - v) * t));
|
|
940
|
+
|
|
941
|
+
/** The look in the file, or nothing. Never throws. */
|
|
942
|
+
export function readLook(file = LOOK_FILE) {
|
|
943
|
+
try {
|
|
944
|
+
const data = JSON.parse(readFileSync(file, 'utf8'));
|
|
945
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
|
946
|
+
} catch {
|
|
947
|
+
return {};
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/** The current look, filled out: what applyLook settled on. */
|
|
952
|
+
export let look = { ...DEFAULT_LOOK };
|
|
953
|
+
|
|
954
|
+
/** Put a look on screen. Missing or broken values fall back to the default. */
|
|
955
|
+
export function applyLook(wanted = {}) {
|
|
956
|
+
const accent = normaliseColour(wanted.accent) ?? DEFAULT_LOOK.accent;
|
|
957
|
+
const own = accent !== DEFAULT_LOOK.accent;
|
|
958
|
+
const light = normaliseColour(wanted.light) ?? (own ? mixHex(accent, '#ffffff', 0.45) : DEFAULT_LOOK.light);
|
|
959
|
+
const deepHex = normaliseColour(wanted.deep) ?? (own ? mixHex(accent, '#000000', 0.25) : DEFAULT_LOOK.deep);
|
|
960
|
+
const spinner = SPINNERS[wanted.spinner] ? wanted.spinner : DEFAULT_LOOK.spinner;
|
|
961
|
+
const byline = typeof wanted.byline === 'string' ? wanted.byline.replace(/[\x00-\x1f]/g, '').slice(0, 40) : DEFAULT_LOOK.byline;
|
|
962
|
+
|
|
963
|
+
look = { accent, light, deep: deepHex, spinner, byline };
|
|
964
|
+
blue = chalk.hex(accent);
|
|
965
|
+
sky = chalk.hex(light);
|
|
966
|
+
deep = chalk.hex(deepHex);
|
|
967
|
+
edge = chalk.hex(accent).bold;
|
|
968
|
+
theme.blue = blue;
|
|
969
|
+
theme.sky = sky;
|
|
970
|
+
theme.deep = deep;
|
|
971
|
+
GRADIENT_TOP = hexRGB(light);
|
|
972
|
+
GRADIENT_BOTTOM = hexRGB(deepHex);
|
|
973
|
+
BUILD_CHIP = chalk.bgHex(accent).hex('#0b1220').bold;
|
|
974
|
+
PLAN_CHIP = chalk.bgHex(mixHex(deepHex, '#0b1220', 0.65)).hex(light).bold;
|
|
975
|
+
ADD_CHIP = PLAN_CHIP(' + file ');
|
|
976
|
+
SPINNER = SPINNERS[spinner];
|
|
977
|
+
return look;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Change the look and keep it: `changes` are merged into what is saved, a key
|
|
982
|
+
* set to null goes back to its default, and { reset: true } starts over.
|
|
983
|
+
*/
|
|
984
|
+
export function saveLook(changes = {}, { reset = false, file = LOOK_FILE } = {}) {
|
|
985
|
+
const merged = reset ? {} : { ...readLook(file) };
|
|
986
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
987
|
+
if (!(key in DEFAULT_LOOK)) continue;
|
|
988
|
+
if (value === null || value === '') delete merged[key];
|
|
989
|
+
else merged[key] = value;
|
|
990
|
+
}
|
|
991
|
+
// An accent alone gets its own light and dark steps, not the old ones.
|
|
992
|
+
if ('accent' in changes && !('light' in changes)) delete merged.light;
|
|
993
|
+
if ('accent' in changes && !('deep' in changes)) delete merged.deep;
|
|
994
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
995
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
996
|
+
writeFileSync(temp, `${JSON.stringify(merged, null, 2)}\n`);
|
|
997
|
+
renameSync(temp, file);
|
|
998
|
+
return applyLook(merged);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
applyLook(readLook());
|
package/ucode.js
CHANGED
|
@@ -39,6 +39,9 @@ function parseArgs(argv) {
|
|
|
39
39
|
else if (a === '--cwd' || a === '-C') args.cwd = path.resolve(argv[++i]);
|
|
40
40
|
else if (a === '--help' || a === '-h') args.help = true;
|
|
41
41
|
else if (a === '--version' || a === '-v') args.version = true;
|
|
42
|
+
else if (a === '--print' || a === '-p') args.print = argv[i + 1] && !argv[i + 1].startsWith('-') ? argv[++i] : '-';
|
|
43
|
+
else if (a === '--json') args.json = true;
|
|
44
|
+
else if (a === '--yes' || a === '-y') args.yes = true;
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
return args;
|
|
@@ -59,10 +62,16 @@ async function usage() {
|
|
|
59
62
|
' -C, --cwd <dir> work in another directory\n' +
|
|
60
63
|
' --plan start in plan mode: read and research, change nothing\n' +
|
|
61
64
|
' --debug print stack traces when something breaks\n' +
|
|
65
|
+
' -p, --print <task> do one task with no keyboard, print the answer, exit\n' +
|
|
66
|
+
' --json with -p: print one JSON object about the run instead\n' +
|
|
67
|
+
' -y, --yes with -p: say yes to anything that would be asked\n' +
|
|
62
68
|
' -v, --version print the version\n' +
|
|
63
69
|
' -h, --help this message\n' +
|
|
64
70
|
' doctor check that everything ucode needs is working\n' +
|
|
65
|
-
' login <key> save your key for every folder on this machine\n
|
|
71
|
+
' login <key> save your key for every folder on this machine\n' +
|
|
72
|
+
' mcp add <name> <command> [args...] connect an MCP server (--url <url> for a remote one)\n' +
|
|
73
|
+
' mcp list | mcp remove <name>\n\n' +
|
|
74
|
+
` ${sky('Other models')} UCODE_BASE_URL=http://localhost:11434/v1 UCODE_MODEL=<model> ucode (Ollama, offline)\n\n` +
|
|
66
75
|
` ${sky('Models')}\n${models}\n\n` +
|
|
67
76
|
` Needs GEMINI_API_KEY in the environment or in ${ENV_FILE}\n` +
|
|
68
77
|
' Free keys: https://aistudio.google.com/apikey\n\n'
|
|
@@ -92,6 +101,33 @@ async function main() {
|
|
|
92
101
|
return;
|
|
93
102
|
}
|
|
94
103
|
|
|
104
|
+
if (process.argv[2] === 'mcp') {
|
|
105
|
+
const { mcpCommand } = await import('./src/core/mcpcli.js');
|
|
106
|
+
process.stdout.write(`${await mcpCommand(process.argv.slice(3))}\n`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (args.print !== undefined) {
|
|
111
|
+
let prompt = args.print;
|
|
112
|
+
if (prompt === '-') {
|
|
113
|
+
const chunks = [];
|
|
114
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
115
|
+
prompt = Buffer.concat(chunks).toString('utf8').trim();
|
|
116
|
+
}
|
|
117
|
+
if (!prompt) {
|
|
118
|
+
process.stderr.write('ucode -p needs a task: ucode -p "fix the failing test"\n');
|
|
119
|
+
process.exitCode = 2;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const [, { setModel }] = await heavy();
|
|
123
|
+
if (args.model) setModel(args.model);
|
|
124
|
+
const { runHeadless } = await import('./src/core/headless.js');
|
|
125
|
+
const code = await runHeadless({ cwd: args.cwd, prompt, json: args.json, yes: args.yes, plan: args.plan });
|
|
126
|
+
// A one-shot run ends here, whatever handle something left open.
|
|
127
|
+
process.stdout.write('', () => process.exit(code));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
95
131
|
// Update before the session starts, not after it ends.
|
|
96
132
|
//
|
|
97
133
|
// Installing in the background and taking effect "next time" means the run
|