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 CHANGED
@@ -4,6 +4,23 @@ A coding agent that lives in your terminal. It reads your code, edits it, runs
4
4
  your commands, and keeps every conversation on disk. It runs on Google's
5
5
  Gemini models, free with a key.
6
6
 
7
+ ## At a glance
8
+
9
+ | | |
10
+ | --- | --- |
11
+ | **Builds** | a whole app in about a minute, from a starter that already works, opened for you when it does |
12
+ | **Thinks** | a thinking level per step — cheap on easy steps, more on the plan, most when a fix has failed |
13
+ | **Designs** | its own tone, typefaces and accent for every app, and a check that sends the generated look back |
14
+ | **Checks** | types, syntax, related tests, the running server's errors, and the page itself — opened and clicked |
15
+ | **Fixes** | sends problems back to the model, tries a different approach when a fix fails, learns the common ones |
16
+ | **Edits real code** | finds the code first, smallest change in the code's own style; renames by code shape |
17
+ | **Undoes** | `/undo [n]` puts the whole project back, including what commands changed |
18
+ | **Git** | `/diff`, `/commit` with a written message, `/review` for bugs |
19
+ | **Extends** | MCP servers, hooks, skills, your own slash commands |
20
+ | **Restyles** | itself and your terminal, when you ask: "make ucode orange and my terminal navy" |
21
+ | **Automates** | `ucode -p "task" --json` for scripts and CI; `npm run eval` runs ten real jobs |
22
+ | **Stays free** | Gemini's free tier, paced to its per-minute limit — or Ollama, offline |
23
+
7
24
  It opens on a quiet screen — the name, the place to type, and the version in the
8
25
  corner:
9
26
 
@@ -19,7 +36,7 @@ corner:
19
36
  ╭──────────────────────────────────────────────────────────────────────────────╮
20
37
  │ › Ask anything… │
21
38
  │ │
22
- │ BUILD North Mini Code 0% │
39
+ │ BUILD Gemini 3.5 Flash-Lite 0% │
23
40
  ╰──────────────────────────────────────────────────────────────────────────────╯
24
41
 
25
42
  try build me a landing page for a coffee shop
@@ -27,7 +44,7 @@ corner:
27
44
  add a dark mode toggle that remembers the choice
28
45
 
29
46
 
30
- v1.62.7
47
+ v1.65.0
31
48
  ```
32
49
 
33
50
  A light crosses the wordmark once as it opens, and the three lines under the box
@@ -49,7 +66,7 @@ The dashboard is at http://localhost:3000, and `npm run dev` brings it back up.
49
66
  ╭──────────────────────────────────────────────────────────────────────────────────╮
50
67
  │ › now add a dark mode toggle │
51
68
  │ │
52
- │ BUILD North Mini Code 4% │
69
+ │ BUILD Gemini 3.5 Flash-Lite 4% │
53
70
  ╰──────────────────────────────────────────────────────────────────────────────────╯
54
71
  ```
55
72
 
@@ -101,7 +118,7 @@ When Google is overloaded and Flash-Lite stops answering, ucode carries on with
101
118
 
102
119
  ## What it does
103
120
 
104
- **Twenty-one tools.** `create_app`, `read_file`, `read_files`, `write_file`,
121
+ **Twenty-one tools, and any MCP server you add.** `create_app`, `read_file`, `read_files`, `write_file`,
105
122
  `batch_write`, `edit_file`, `multi_edit`, `edit_files`, `rename_symbol`,
106
123
  `find_symbol`, `outline`, `type_of`, `add_block`, `list_dir`, `glob`, `grep`,
107
124
  `run_command`, `run_commands`, `look_at_app`, `web_search`, `deploy`. Read-only
@@ -253,7 +270,7 @@ check and a screenshot; the only way to find out is to press something.
253
270
 
254
271
  It also reports console errors, failed requests, content that spills off a
255
272
  phone screen, broken images and unlabeled controls, saves screenshots to
256
- `.ucode/screenshots`, and has Nemotron Nano Omni review them the way a designer
273
+ `.ucode/screenshots`, and has Gemini review them the way a designer
257
274
  would. The model fixes what it finds before calling the app done. Both widths load at once, and the designer review — the slow part — runs
258
275
  on the first look at an app in each request and is skipped, not waited on, when
259
276
  the vision model is busy. The look after the fixes re-runs only the fast checks:
@@ -365,7 +382,14 @@ Everything after the frontmatter is the instruction.
365
382
  | `/session delete 2,5` | delete saved conversations by number (or `d d` in the list) |
366
383
  | `/new` | save this one and start fresh |
367
384
  | `/remember <note>` | add a standing note to this project's `UCODE.md` |
368
- | `/undo` | put back every file the last turn changed |
385
+ | `/undo [n]` | put the project back as it was before the last turn — or `n` turns — including what commands changed |
386
+ | `/diff` | what has changed this session |
387
+ | `/commit [msg]` | commit the changes, with a message written from the diff if you give none |
388
+ | `/review` | read the uncommitted changes for bugs, changing nothing |
389
+ | `/init` | read the project and write its `UCODE.md` |
390
+ | `/mcp` | connected MCP servers and their tools |
391
+ | `/permissions [ask\|auto]` | ask before every command, or run them; what is always allowed |
392
+ | `/theme [what]` | change how ucode or your terminal looks — or just ask in words |
369
393
  | `/look [url]` | open the running app and report what is on the page |
370
394
  | `/deploy [folder]` | put the app online and get its link |
371
395
  | `/mic` | say what you want instead of typing it — same as `ctrl+t` |
@@ -393,6 +417,112 @@ Recording uses what the computer already has: Windows' built-in recorder, `sox`
393
417
  or `ffmpeg` on macOS (`brew install sox`), `arecord` or `sox` on Linux. If a
394
418
  quiet mic is taken for silence, set `UCODE_MIC_QUIET` lower than 800.
395
419
 
420
+ ### Change how it looks — and your terminal
421
+
422
+ Just ask: "make ucode orange with the arc spinner", "put my name under the
423
+ logo", "make my terminal navy with a bigger font", "make the terminal a bit
424
+ see-through". Or use `/theme`:
425
+
426
+ ```
427
+ /theme what it looks like now, and the choices
428
+ /theme orange a new colour at once (a name, #ff8c2b, or rgb(...))
429
+ /theme reset ucode's own blue again
430
+ /theme terminal reset the terminal back the way it was
431
+ ```
432
+
433
+ ucode's look is saved in `~/.ucode/theme.json` — accent, spinner (`dots`,
434
+ `line`, `arc`, `circle`, `square`, `bounce`, `pulse`, `star`) and the line
435
+ under the logo — so it survives restarts and updates.
436
+
437
+ The terminal is changed the way each one allows, after you say yes:
438
+
439
+ | Terminal | What changes | How long |
440
+ | --- | --- | --- |
441
+ | Windows Terminal | background, text, cursor, font, size, opacity | kept, every tab (the old settings are backed up) |
442
+ | Terminal.app (macOS) | background, text, cursor, font, size | this window |
443
+ | iTerm2 (macOS) | background, text, cursor | this session |
444
+ | Linux, VS Code and others | background, text, cursor | this session |
445
+
446
+ ### Your own commands
447
+
448
+ A file `.ucode/commands/explain.md` (or `~/.ucode/commands/` for every
449
+ project) becomes `/explain`. Its text is the prompt; `$ARGUMENTS` is replaced
450
+ by whatever you type after the command.
451
+
452
+ ### MCP servers
453
+
454
+ Connect tools from any MCP server — library docs, GitHub, a database:
455
+
456
+ ```
457
+ ucode mcp add context7 npx -y @upstash/context7-mcp
458
+ ucode mcp add github --url https://api.githubcopilot.com/mcp/ --header "Authorization=Bearer ${GITHUB_TOKEN}"
459
+ ucode mcp list
460
+ ucode mcp remove github
461
+ ```
462
+
463
+ They are saved in `~/.ucode/mcp.json` (`--project` puts them in this folder's
464
+ `.ucode/mcp.json`). ucode asks before each MCP tool runs; answer `a` to always
465
+ allow that tool. A project's own servers and hooks only run once you approve them.
466
+
467
+ ### Permissions and hooks
468
+
469
+ `.ucode/settings.json` (or `~/.ucode/settings.json`):
470
+
471
+ ```json
472
+ {
473
+ "commands": "ask",
474
+ "allow": ["npm test", "git status"],
475
+ "hooks": {
476
+ "afterEdit": ["npx prettier --write {files}"],
477
+ "beforeCommand": ["node guard.js"]
478
+ }
479
+ }
480
+ ```
481
+
482
+ `"commands": "ask"` puts every command to you first (`/permissions ask`);
483
+ answering `a` adds it to `allow`. A `beforeCommand` hook that exits non-zero
484
+ stops the command; it sees it in `UCODE_COMMAND`.
485
+
486
+ ### Run it without a keyboard
487
+
488
+ ```
489
+ ucode -p "fix the failing test" prints the answer
490
+ ucode -p "make a quiz app" --json --yes one JSON line: ok, answer, files, steps, requests, time
491
+ ```
492
+
493
+ Progress goes to stderr. With no `--yes`, anything that would be asked is declined.
494
+ `npm run eval` runs ten real jobs this way and checks each one — use it before
495
+ a release (it spends about 30 free requests, and takes about seven minutes).
496
+
497
+ ### Other models
498
+
499
+ Any OpenAI-compatible server works, Ollama on your own computer included —
500
+ free and offline:
501
+
502
+ ```
503
+ UCODE_BASE_URL=http://localhost:11434/v1 UCODE_MODEL=qwen2.5-coder ucode
504
+ ```
505
+
506
+ ## How it thinks
507
+
508
+ - **Thinking levels.** Flash-Lite does not think at all unless asked. ucode
509
+ asks for a little on every step (it costs nothing on a straightforward
510
+ write), more on the first step of a build, and the most when a fix has
511
+ already failed. `UCODE_THINK=0` turns it off.
512
+ - **A design direction for every build** — a tone, two typefaces and an accent —
513
+ so two apps never come out the same, and a check for the generated look
514
+ (the starter's colours, Inter, purple gradients, gradient text, emoji icons)
515
+ that sends it back to be fixed. `UCODE_DESIGN_CHECK=0` turns the check off.
516
+ - **Learns from its mistakes.** Problems ucode keeps catching are counted in
517
+ `~/.ucode/lessons.json`, and the common ones are warned about before the next build.
518
+ - **Tries a different approach** when the same problem survives a fix, and
519
+ offers to hand that fix to Gemini 3.5 Flash — only if you say yes, since it
520
+ has about 20 free requests a day.
521
+ - **Changes to existing code** get their own rules: find the code, read only
522
+ what is involved, make the smallest change in the code's own style.
523
+ - **Stays under the free limit.** Requests are spaced to Google's per-minute
524
+ limit instead of being refused and waited out; `/stats` shows how many were sent.
525
+
396
526
  ## Options
397
527
 
398
528
  ```
@@ -401,6 +531,9 @@ ucode [options]
401
531
  -m, --model <id> which model to use
402
532
  -C, --cwd <dir> work in another directory
403
533
  --plan start in plan mode
534
+ -p, --print <task> do one task with no keyboard, print the answer, exit
535
+ --json with -p: one JSON object about the run
536
+ -y, --yes with -p: say yes to anything that would be asked
404
537
  --debug print stack traces when something breaks
405
538
  -v, --version print the version
406
539
  -h, --help the above
@@ -410,7 +543,12 @@ ucode [options]
410
543
 
411
544
  | | |
412
545
  | --- | --- |
413
- | `~/.ucode/.env` | `UCODE_API_KEY`, and `TAVILY_API_KEY` for web search |
546
+ | `~/.ucode/.env` | `GEMINI_API_KEY`, and `TAVILY_API_KEY` for web search |
547
+ | `~/.ucode/settings.json`, `.ucode/settings.json` | permissions, always-allowed commands, hooks |
548
+ | `~/.ucode/mcp.json`, `.ucode/mcp.json` | MCP servers |
549
+ | `.ucode/commands/*.md` | your own slash commands |
550
+ | `~/.ucode/snapshots/` | the project before each turn, for `/undo` |
551
+ | `~/.ucode/lessons.json` | mistakes ucode keeps catching, warned about next time |
414
552
  | `~/.ucode/sessions/` | one JSON per conversation |
415
553
  | `.ucode/skills/` | skills belonging to a project |
416
554
  | `UCODE.md` | project memory, read every turn |
@@ -420,7 +558,11 @@ Environment overrides: `UCODE_MODEL`, `UCODE_WORKER_MODEL` (a faster model for
420
558
  parallel workers), `UCODE_WORKER_STEPS`, `UCODE_MAX_CONTEXT_TOKENS`,
421
559
  `UCODE_MAX_STEPS`, `UCODE_MAX_TOOL_OUTPUT`, `UCODE_REQUEST_TIMEOUT_MS`,
422
560
  `UCODE_STALL_MS` (how long a silent reply is waited on before asking again, 60s),
423
- `UCODE_BASE_URL`, `UCODE_NO_UPDATE`.
561
+ `UCODE_BASE_URL`, `UCODE_NO_UPDATE`, `UCODE_THINK=0`, `UCODE_DESIGN_CHECK=0`,
562
+ `UCODE_RPM` (requests a minute before pacing, 0 = off), `UCODE_RIPGREP=0`.
563
+
564
+ Search uses ripgrep (`rg`) when it is installed — much faster on a big
565
+ project — and its own search otherwise.
424
566
 
425
567
  Web search needs a Tavily key — free, 1000 searches a month, no card. Without
426
568
  one, ucode answers from what it knows and says that it could not check.
@@ -436,6 +578,13 @@ src/core/window.js folding a long conversation to fit
436
578
  src/core/skills.js loading skills, and deciding which load themselves
437
579
  src/core/context.js the project map and project memory
438
580
  src/core/failure.js one error shape: what, why, what next
581
+ src/core/scope.js what a request carries: build scope, design direction, edit rules
582
+ src/core/genericcheck.js the check for a generated-looking design
583
+ src/core/lessons.js mistakes counted across sessions, warned about up front
584
+ src/core/snapshot.js the project before every turn, for /undo
585
+ src/core/settings.js permissions, always-allow, hooks, project trust
586
+ src/core/mcp.js the MCP client: stdio and HTTP servers, no SDK
587
+ src/core/headless.js ucode -p: one job, no keyboard
439
588
  src/tools/ the twenty-one tools, plus their shared plumbing
440
589
  src/ui/screen.js the full-screen interface
441
590
  src/ui/plain.js the same interface for when there is no terminal
package/package.json CHANGED
@@ -1,64 +1,65 @@
1
- {
2
- "name": "ucode-agent",
3
- "version": "1.63.0",
4
- "description": "ucode - a terminal coding agent that reads, edits and runs your code, on Google Gemini models.",
5
- "type": "module",
6
- "main": "ucode.js",
7
- "bin": {
8
- "ucode": "ucode.js"
9
- },
10
- "files": [
11
- "ucode.js",
12
- "src/",
13
- "skills/",
14
- "templates/",
15
- "THIRD_PARTY_NOTICES.md",
16
- "LICENSE-APACHE"
17
- ],
18
- "scripts": {
19
- "start": "node ucode.js",
20
- "test": "node test/run.js",
21
- "prepublishOnly": "node scripts/no-bundled-key.js && node test/run.js",
22
- "hooks": "node scripts/install-hooks.js"
23
- },
24
- "repository": {
25
- "type": "git",
26
- "url": "git+https://github.com/sppideey/ucode-agent.git"
27
- },
28
- "bugs": {
29
- "url": "https://github.com/sppideey/ucode-agent/issues"
30
- },
31
- "homepage": "https://github.com/sppideey/ucode-agent#readme",
32
- "engines": {
33
- "node": ">=22"
34
- },
35
- "keywords": [
36
- "agent",
37
- "cli",
38
- "terminal",
39
- "coding-agent",
40
- "llm",
41
- "gemini",
42
- "google-gemini",
43
- "ai"
44
- ],
45
- "author": "om dixit",
46
- "license": "(MIT OR Apache-2.0)",
47
- "dependencies": {
48
- "@babel/parser": "^7.29.9",
49
- "chalk": "^6.0.0",
50
- "dotenv": "^18.0.3",
51
- "jsonrepair": "^3.15.0",
52
- "marked": "^15.0.12",
53
- "marked-terminal": "^7.3.0",
54
- "openai": "^7.23.0",
55
- "playwright-core": "^1.63.0"
56
- },
57
- "devDependencies": {
58
- "@types/node": "^26.6.2",
59
- "typescript": "^5.9.3"
60
- },
61
- "publishConfig": {
62
- "access": "public"
63
- }
64
- }
1
+ {
2
+ "name": "ucode-agent",
3
+ "version": "1.65.0",
4
+ "description": "ucode - a terminal coding agent that reads, edits and runs your code, on Google Gemini models.",
5
+ "type": "module",
6
+ "main": "ucode.js",
7
+ "bin": {
8
+ "ucode": "ucode.js"
9
+ },
10
+ "files": [
11
+ "ucode.js",
12
+ "src/",
13
+ "skills/",
14
+ "templates/",
15
+ "THIRD_PARTY_NOTICES.md",
16
+ "LICENSE-APACHE"
17
+ ],
18
+ "scripts": {
19
+ "start": "node ucode.js",
20
+ "test": "node test/run.js",
21
+ "prepublishOnly": "node scripts/no-bundled-key.js && node test/run.js",
22
+ "hooks": "node scripts/install-hooks.js",
23
+ "eval": "node test/evals/run.js"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/sppideey/ucode-agent.git"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/sppideey/ucode-agent/issues"
31
+ },
32
+ "homepage": "https://github.com/sppideey/ucode-agent#readme",
33
+ "engines": {
34
+ "node": ">=22"
35
+ },
36
+ "keywords": [
37
+ "agent",
38
+ "cli",
39
+ "terminal",
40
+ "coding-agent",
41
+ "llm",
42
+ "gemini",
43
+ "google-gemini",
44
+ "ai"
45
+ ],
46
+ "author": "om dixit",
47
+ "license": "(MIT OR Apache-2.0)",
48
+ "dependencies": {
49
+ "@babel/parser": "^7.29.9",
50
+ "chalk": "^6.0.0",
51
+ "dotenv": "^18.0.3",
52
+ "jsonrepair": "^3.15.0",
53
+ "marked": "^15.0.12",
54
+ "marked-terminal": "^7.3.0",
55
+ "openai": "^7.23.0",
56
+ "playwright-core": "^1.63.0"
57
+ },
58
+ "devDependencies": {
59
+ "@types/node": "^26.6.2",
60
+ "typescript": "^5.9.3"
61
+ },
62
+ "publishConfig": {
63
+ "access": "public"
64
+ }
65
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * commands.js — slash commands the user writes themselves.
3
+ *
4
+ * ~/.ucode/commands/<name>.md yours, everywhere
5
+ * <project>/.ucode/commands/<name>.md this project's (wins over yours)
6
+ *
7
+ * The file is a prompt. `/name some words` sends it, with $ARGUMENTS replaced
8
+ * by the words — or the words added at the end when the file has no
9
+ * $ARGUMENTS. Its first line is what /help shows.
10
+ */
11
+
12
+ import { promises as fs } from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+
16
+ export const USER_COMMANDS = path.join(os.homedir(), '.ucode', 'commands');
17
+ export const projectCommands = (cwd) => path.join(cwd, '.ucode', 'commands');
18
+
19
+ async function readDir(dir) {
20
+ const found = new Map();
21
+ for (const entry of await fs.readdir(dir, { withFileTypes: true }).catch(() => [])) {
22
+ if (!entry.isFile() || !/\.md$/i.test(entry.name)) continue;
23
+ const name = entry.name.replace(/\.md$/i, '').toLowerCase();
24
+ if (!/^[a-z0-9][\w-]*$/.test(name)) continue;
25
+ const body = await fs.readFile(path.join(dir, entry.name), 'utf8').catch(() => null);
26
+ if (!body?.trim()) continue;
27
+ const first = body.trim().split('\n')[0].replace(/^#+\s*/, '').trim();
28
+ found.set(name, { name, body: body.trim(), description: first.slice(0, 70) });
29
+ }
30
+ return found;
31
+ }
32
+
33
+ /** Every command, by name without the slash. */
34
+ export async function loadCommands(cwd, { userDir = USER_COMMANDS } = {}) {
35
+ const [mine, project] = await Promise.all([readDir(userDir), readDir(projectCommands(cwd))]);
36
+ return new Map([...mine, ...project]);
37
+ }
38
+
39
+ /** The prompt a command sends, given what was typed after it. */
40
+ export function expandCommand(body, args = '') {
41
+ const words = String(args).trim();
42
+ if (body.includes('$ARGUMENTS')) return body.split('$ARGUMENTS').join(words);
43
+ return words ? `${body}\n\n${words}` : body;
44
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * genericcheck.js — the generated look, caught in code.
3
+ *
4
+ * The rules against it were already written down, in the system prompt and
5
+ * the ui-ux skill: not the starter's palette, not Inter at every size, not a
6
+ * purple-to-blue gradient, not emoji standing in for icons. Flash-Lite reads
7
+ * them and ships the starter's teal anyway. A rule the model can skip is a
8
+ * suggestion; a check that hands the problem back is a rule.
9
+ *
10
+ * Everything here is a few regular expressions over files already on disk —
11
+ * milliseconds, no model call. Only a hit costs anything: one fix round.
12
+ */
13
+
14
+ import { promises as fs } from 'node:fs';
15
+ import path from 'node:path';
16
+
17
+ /** The starter's own accent, which means nobody chose one. */
18
+ const STARTER = {
19
+ 'plain-html': { files: ['styles.css'], token: /--accent\s*:\s*#2dd4bf\b/i },
20
+ 'next-shadcn': { files: ['src/app/globals.css'], token: /--primary\s*:\s*oklch\(\s*0\.53\s+0\.2\s+264\s*\)/i },
21
+ };
22
+
23
+ /** Files that carry a Next.js app's look. */
24
+ const LOOK_FILES = {
25
+ 'next-shadcn': ['src/app/globals.css', 'src/app/layout.tsx', 'src/app/page.tsx'],
26
+ };
27
+
28
+ const NAMED = {
29
+ purple: 300, violet: 300, indigo: 275, blueviolet: 271, mediumpurple: 260, rebeccapurple: 270,
30
+ darkviolet: 282, slateblue: 248, mediumslateblue: 249, darkslateblue: 248, magenta: 300, fuchsia: 300,
31
+ blue: 240, royalblue: 225, mediumblue: 240, dodgerblue: 210, cornflowerblue: 219,
32
+ };
33
+
34
+ /** Hue in degrees of one colour, or null for a grey or something unreadable. */
35
+ export function hueOf(colour) {
36
+ const c = String(colour).trim().toLowerCase();
37
+ if (NAMED[c] !== undefined) return NAMED[c];
38
+
39
+ let r; let g; let b;
40
+ const hex = /^#([0-9a-f]{3,8})$/.exec(c);
41
+ if (hex) {
42
+ let h = hex[1];
43
+ if (h.length === 3 || h.length === 4) h = [...h.slice(0, 3)].map((x) => x + x).join('');
44
+ [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
45
+ }
46
+ const rgb = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/.exec(c);
47
+ if (rgb) [r, g, b] = rgb.slice(1, 4).map((v) => Number(v) / 255);
48
+ const hsl = /^hsla?\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%/.exec(c);
49
+ if (hsl) return Number(hsl[2]) < 25 ? null : Number(hsl[1]) % 360;
50
+ const lch = /^oklch\(\s*[\d.]+%?\s+([\d.]+)\s+([\d.]+)/.exec(c);
51
+ // oklch puts blue near 264 and purple near 300-310; shift to the same wheel as hsl.
52
+ if (lch) return Number(lch[1]) < 0.06 ? null : (Number(lch[2]) - 25 + 360) % 360;
53
+ if (r === undefined) return null;
54
+
55
+ const max = Math.max(r, g, b);
56
+ const min = Math.min(r, g, b);
57
+ const d = max - min;
58
+ const light = (max + min) / 2;
59
+ const sat = d === 0 ? 0 : d / (1 - Math.abs(2 * light - 1));
60
+ if (sat < 0.25) return null;
61
+ let hue;
62
+ if (max === r) hue = ((g - b) / d) % 6;
63
+ else if (max === g) hue = (b - r) / d + 2;
64
+ else hue = (r - g) / d + 4;
65
+ return Math.round((hue * 60 + 360) % 360);
66
+ }
67
+
68
+ /** Every gradient's argument list, brackets balanced. */
69
+ function gradients(css) {
70
+ const found = [];
71
+ const re = /(?:linear|radial|conic)-gradient\(/gi;
72
+ let m;
73
+ while ((m = re.exec(css))) {
74
+ let depth = 1;
75
+ let i = re.lastIndex;
76
+ for (; i < css.length && depth; i++) {
77
+ if (css[i] === '(') depth++;
78
+ else if (css[i] === ')') depth--;
79
+ }
80
+ found.push(css.slice(re.lastIndex, i - 1));
81
+ }
82
+ return found;
83
+ }
84
+
85
+ const COLOUR = /#[0-9a-f]{3,8}\b|(?:rgba?|hsla?|oklch)\([^)]*\)|\b[a-z]+\b/gi;
86
+
87
+ /** The purple-to-blue gradient: every coloured stop blue-to-purple, at least one of them purple. */
88
+ export function purpleGradient(css) {
89
+ return gradients(css).some((args) => {
90
+ const hues = (args.match(COLOUR) ?? []).map(hueOf).filter((h) => h !== null);
91
+ return hues.length >= 2 && hues.every((h) => h >= 200 && h <= 330) && hues.some((h) => h >= 250);
92
+ });
93
+ }
94
+
95
+ /**
96
+ * The files that make up the page. For a plain page that is index.html and
97
+ * whatever it actually links: a starter stylesheet left behind, unlinked,
98
+ * says nothing about how the app looks.
99
+ */
100
+ async function lookFiles(dir, template) {
101
+ if (template !== 'plain-html') return LOOK_FILES[template] ?? [];
102
+ const html = await fs.readFile(path.join(dir, 'index.html'), 'utf8').catch(() => '');
103
+ const linked = [...html.matchAll(/<(?:link|script)\b[^>]*\b(?:href|src)\s*=\s*["']([^"'?#]+)/gi)]
104
+ .map((m) => m[1])
105
+ .filter((f) => !/^(?:[a-z]+:)?\/\//i.test(f) && /\.(?:css|m?js)$/i.test(f));
106
+ return ['index.html', ...new Set(linked)];
107
+ }
108
+
109
+ /** Problems with the look of the app in `dir`, as lines for the model. Never throws. */
110
+ export async function genericLook(dir, template = 'plain-html') {
111
+ const files = await lookFiles(dir, template);
112
+ const texts = await Promise.all(files.map((f) => fs.readFile(path.join(dir, f), 'utf8').catch(() => '')));
113
+ const all = texts.join('\n');
114
+ if (!all.trim()) return [];
115
+
116
+ const problems = [];
117
+ const starter = STARTER[template];
118
+ if (starter) {
119
+ const own = starter.files.filter((f) => files.includes(f) || template !== 'plain-html');
120
+ const texts2 = await Promise.all(own.map((f) => fs.readFile(path.join(dir, f), 'utf8').catch(() => '')));
121
+ if (texts2.some((t) => starter.token.test(t))) {
122
+ problems.push('The accent is still the starter\'s own colour, so the app looks like every other one built from it. Pick an accent for this app and set it in the tokens.');
123
+ }
124
+ }
125
+ if (/font-family\s*:\s*["']?Inter["']?\s*[,;}]|--font-[\w-]+\s*:\s*["']?Inter["']?\s*[,;]|family=Inter(?![+\w])|import\s*\{[^}]*\bInter\b[^}]*\}\s*from\s*["']next\/font\/google/i.test(all)) {
126
+ problems.push('The type is Inter, the default of generated apps. Choose a typeface with a character that fits this app.');
127
+ }
128
+ if (purpleGradient(all)) {
129
+ problems.push('There is a purple-to-blue gradient, the most recognisable mark of a generated design. Use the app\'s own accent, flat or with a quiet tint.');
130
+ }
131
+ if (/(?:-webkit-)?background-clip\s*:\s*text/i.test(all)) {
132
+ problems.push('There is gradient text (background-clip: text). Set headings in a solid colour and let the type carry them.');
133
+ }
134
+ const emoji = all.match(/<(?:button|h[1-6])\b[^>]*>\s*\p{Extended_Pictographic}/gu) ?? [];
135
+ if (emoji.length >= 3) {
136
+ problems.push(`Emoji stand in for icons in ${emoji.length} buttons or headings. Use small inline SVG icons or plain words.`);
137
+ }
138
+ return problems;
139
+ }
140
+
141
+ /** The fix-round text for a list of look problems. */
142
+ export function genericMessage(problems) {
143
+ return 'ucode checked the design for the generated look and found:\n' +
144
+ problems.map((p) => `- ${p}`).join('\n') +
145
+ '\nFix these in the design tokens and styles only - a new accent, a new typeface. Do not ' +
146
+ 'restructure the app or change what it does.';
147
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * headless.js — `ucode -p "task"`: one job, no keyboard, then exit.
3
+ *
4
+ * For scripts, CI and the eval set. Progress goes to stderr, the answer to
5
+ * stdout (or, with --json, one JSON object describing the run), and the exit
6
+ * code says whether it worked. Nobody is there to approve anything, so every
7
+ * question is answered no — unless --yes says to answer yes.
8
+ */
9
+
10
+ import { Readable } from 'node:stream';
11
+ import { Plain } from '../ui/plain.js';
12
+ import { Agent } from './loop.js';
13
+ import { requestCount } from './provider.js';
14
+ import { stopServers } from '../tools/shell.js';
15
+ import { closeBrowser } from '../tools/browser.js';
16
+
17
+ export class Headless extends Plain {
18
+ constructor({ cwd, yes = false }) {
19
+ super({ cwd, input: Readable.from([]), output: process.stderr });
20
+ this.yes = yes;
21
+ this.answer = '';
22
+ }
23
+
24
+ assistant(text, opts = {}) {
25
+ super.assistant(text, opts);
26
+ if (!opts.replay && String(text).trim()) this.answer = String(text).trim();
27
+ }
28
+
29
+ confirm({ action }) {
30
+ this.note(`${this.yes ? 'approved' : 'declined'} (no one to ask): ${action}`);
31
+ return Promise.resolve(this.yes);
32
+ }
33
+ }
34
+
35
+ /** Run one prompt. Resolves to the exit code. */
36
+ export async function runHeadless({ cwd, prompt, json = false, yes = false, plan = false, write = (s) => process.stdout.write(s) }) {
37
+ const ui = new Headless({ cwd, yes });
38
+ const agent = new Agent({ cwd, ui });
39
+ if (plan) ui.mode = 'plan';
40
+ const started = Date.now();
41
+ const sent = requestCount();
42
+ let error = null;
43
+ try {
44
+ await agent.bootstrap();
45
+ agent.startMcp();
46
+ await agent.mcpStarting;
47
+ await agent.turn(prompt);
48
+ } catch (err) {
49
+ error = err;
50
+ ui.error(err);
51
+ } finally {
52
+ stopServers();
53
+ agent.mcp?.close();
54
+ await closeBrowser().catch(() => {});
55
+ await agent.settled?.().catch(() => {});
56
+ }
57
+
58
+ const ok = !error && !agent.endedSilently;
59
+ if (json) {
60
+ write(`${JSON.stringify({
61
+ ok,
62
+ answer: ui.answer,
63
+ files: [...(agent.touched ?? [])],
64
+ steps: agent.stats.steps,
65
+ requests: requestCount() - sent,
66
+ ms: Date.now() - started,
67
+ error: error ? (error.failed ?? error.message ?? String(error)) : null,
68
+ })}\n`);
69
+ } else if (ui.answer) {
70
+ write(`${ui.answer}\n`);
71
+ }
72
+ return ok ? 0 : 1;
73
+ }