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.
@@ -10,6 +10,7 @@
10
10
  import { promises as fs } from 'node:fs';
11
11
  import path from 'node:path';
12
12
  import { ToolFailure, Declined } from '../core/failure.js';
13
+ import { isAllowed, commandKey } from '../core/settings.js';
13
14
 
14
15
  /**
15
16
  * How much of a result the model may see.
@@ -63,7 +64,25 @@ export function setConfirm(fn) {
63
64
  asker = fn;
64
65
  }
65
66
 
66
- export async function confirm(action, detail, risk = 'write') {
67
+ /**
68
+ * What may run without asking. `askCommands` is the "commands": "ask" setting;
69
+ * `allow` is the always-allow list; `onAlways` saves a new entry to it.
70
+ */
71
+ let policy = { askCommands: false, allow: [], onAlways: null };
72
+
73
+ export function setPolicy(next) {
74
+ policy = { ...policy, ...next };
75
+ }
76
+
77
+ /** Is this command, or "mcp:tool", on the always-allow list? */
78
+ export const allowedNow = (subject) => isAllowed(policy.allow, subject);
79
+
80
+ /**
81
+ * Ask before acting. `always` names what an "always" answer allows from then
82
+ * on: a command's first words, or "mcp:tool". Callers check allowedNow()
83
+ * against the whole command first — the entry is only what gets saved.
84
+ */
85
+ export async function confirm(action, detail, risk = 'write', always = null) {
67
86
  if (!asker) {
68
87
  throw new ToolFailure({
69
88
  kind: 'cannot_ask',
@@ -72,7 +91,19 @@ export async function confirm(action, detail, risk = 'write') {
72
91
  fix: 'Run ucode in a terminal so it can prompt before acting.',
73
92
  });
74
93
  }
75
- if (!(await asker({ action, detail, risk }))) throw new Declined(action);
94
+ const answer = await asker({ action, detail, risk, always });
95
+ if (answer === 'always' && always) {
96
+ policy = { ...policy, allow: [...policy.allow, always] };
97
+ await Promise.resolve(policy.onAlways?.(always)).catch(() => {});
98
+ return;
99
+ }
100
+ if (!answer) throw new Declined(action);
101
+ }
102
+
103
+ /** A command about to run, put to the user first when they asked for that. */
104
+ export async function approveCommand(command, where) {
105
+ if (!policy.askCommands || allowedNow(command)) return;
106
+ await confirm(`run ${String(command).trim().slice(0, 160)}`, `in ${where}`, 'command', commandKey(command));
76
107
  }
77
108
 
78
109
  // ---------------------------------------------------------------------------
@@ -14,7 +14,7 @@ import { openSync, closeSync, readFileSync, mkdirSync, statSync } from 'node:fs'
14
14
  import os from 'node:os';
15
15
  import path from 'node:path';
16
16
  import { ToolFailure } from '../core/failure.js';
17
- import { resolveIn, guard, confirm, result, getRoot, MAX_OUTPUT } from './shared.js';
17
+ import { resolveIn, guard, confirm, approveCommand, result, getRoot, MAX_OUTPUT } from './shared.js';
18
18
 
19
19
  const DEFAULT_TIMEOUT = 120_000;
20
20
  const MAX_TIMEOUT = 600_000;
@@ -713,6 +713,9 @@ export async function runCommand({ command, cwd, timeout_ms, background }, { onO
713
713
  : { abs: getRoot(), inside: true, show: '.' };
714
714
  await guard(workdir, `run a command in ${workdir.abs}`);
715
715
 
716
+ // "commands": "ask" in settings: every command is put to the user first.
717
+ await approveCommand(command, workdir.show);
718
+
716
719
  const env = childEnv();
717
720
  const server = startsServer(command);
718
721
 
@@ -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([0x2f, 0x6f, 0xe0], [0x9f, 0xc6, 0xff], k);
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
- ['', 'made with ❤ by om dixit'],
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
- const badge = risk === 'command' ? ' shell ' : ' outside project ';
299
- this.output.write(`\n${chalk.inverse(theme.warn(badge))} ${chalk.white(action)}\n`);
301
+ this.output.write(`\n${chalk.inverse(theme.warn(badge(risk)))} ${chalk.white(action)}\n`);
300
302
  for (const line of String(detail ?? '').split('\n')) {
301
303
  if (line) this.output.write(dim(` ${line}\n`));
302
304
  }
303
305
 
304
- const answer = await this.promptWith(`${blue(' go ahead? ')}${dim('[y/N] ')}`);
306
+ const answer = await this.promptWith(`${blue(' go ahead? ')}${dim(always ? `[y/N, a = always allow ${always}] ` : '[y/N] ')}`);
305
307
  // End of input is a no: never run something nobody approved.
306
- const yes = /^(y|yes)$/i.test(String(answer ?? '').trim());
307
- this.output.write(dim(yes ? ' approved\n\n' : ' declined\n\n'));
308
+ const said = String(answer ?? '').trim();
309
+ const yes = always && /^(a|always)$/i.test(said) ? 'always' : /^(y|yes)$/i.test(said);
310
+ this.output.write(dim(yes === 'always' ? ` approved — ${always} is always allowed here now\n\n` : yes ? ' approved\n\n' : ' declined\n\n'));
308
311
  return yes;
309
312
  }
310
313
 
package/src/ui/screen.js CHANGED
@@ -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(['', 'made with ❤ by om dixit']);
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 === 'command' ? ' shell ' : ' outside project '))} ${chalk.white(action)}`);
1209
+ this.push(`${chalk.inverse(theme.warn(badge(risk)))} ${chalk.white(action)}`);
1209
1210
  for (const line of String(detail ?? '').split('\n')) {
1210
1211
  if (line) this.push(dim(` ${line}`));
1211
1212
  }
1212
1213
 
1213
- this.pendingPrompt = 'go ahead? [y/N]';
1214
+ this.pendingPrompt = always ? `go ahead? [y/N, a = always allow ${always}]` : 'go ahead? [y/N]';
1214
1215
  this.scroll = 0; // the question has to be on screen to be answered
1215
1216
  this.render();
1216
1217
 
1217
1218
  return this.nextLine().then((answer) => {
1218
1219
  this.pendingPrompt = null;
1219
1220
  // End of input counts as no. Never run something nobody approved.
1220
- const yes = /^(y|yes)$/i.test(String(answer ?? '').trim());
1221
- this.push(dim(yes ? ' approved' : ' declined'));
1221
+ const said = String(answer ?? '').trim();
1222
+ const yes = always && /^(a|always)$/i.test(said) ? 'always' : /^(y|yes)$/i.test(said);
1223
+ this.push(dim(yes === 'always' ? ` approved — ${always} is always allowed here now` : yes ? ' approved' : ' declined'));
1222
1224
  this.push('');
1223
1225
  return yes;
1224
1226
  });
package/src/ui/theme.js CHANGED
@@ -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
- export const blue = chalk.hex('#4d8dff'); // structure: borders, the caret, the wordmark
16
- export const sky = chalk.hex('#8fbcff'); // secondary: labels that still matter
17
- export const deep = chalk.hex('#2f6fe0'); // pressed, quiet, behind
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 const edge = chalk.hex('#4d8dff').bold;
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
- const GRADIENT_TOP = [0x8f, 0xbc, 0xff]; // sky, at the crown
91
- const GRADIENT_BOTTOM = [0x2f, 0x6f, 0xe0]; // deep, in the shadow
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 const BUILD_CHIP = chalk.bgHex('#4d8dff').hex('#0b1220').bold;
125
- export const PLAN_CHIP = chalk.bgHex('#24344f').hex('#8fbcff').bold;
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 const ADD_CHIP = PLAN_CHIP(' + file ');
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 const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
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 = String(text ?? '').replace(/\r/g, '').split('\n');
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\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