ucode-agent 1.5.0 → 1.6.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.
Files changed (58) hide show
  1. package/README.md +53 -1
  2. package/package.json +3 -1
  3. package/skills/ui-ux/SKILL.md +2 -2
  4. package/src/core/doctor.js +122 -0
  5. package/src/core/loop.js +301 -12
  6. package/src/core/provider.js +93 -10
  7. package/src/core/stuck.js +269 -0
  8. package/src/tools/browser.js +121 -59
  9. package/src/tools/deploy.js +283 -0
  10. package/src/tools/files.js +91 -8
  11. package/src/tools/index.js +44 -10
  12. package/src/tools/scaffold.js +61 -1
  13. package/src/tools/shell.js +89 -1
  14. package/src/ui/activity.js +203 -0
  15. package/src/ui/plain.js +22 -3
  16. package/src/ui/screen.js +65 -19
  17. package/templates/next-shadcn/TEMPLATE.md +53 -9
  18. package/templates/next-shadcn/_package-lock.json +1335 -148
  19. package/templates/next-shadcn/components.json +1 -1
  20. package/templates/next-shadcn/next.config.ts +2 -1
  21. package/templates/next-shadcn/package.json +4 -2
  22. package/templates/next-shadcn/presets/citrus.json +77 -0
  23. package/templates/next-shadcn/presets/graphite.json +77 -0
  24. package/templates/next-shadcn/presets/grove.json +77 -0
  25. package/templates/next-shadcn/presets/ocean.json +78 -0
  26. package/templates/next-shadcn/presets/sunset.json +77 -0
  27. package/templates/next-shadcn/presets/violet.json +77 -0
  28. package/templates/next-shadcn/src/components/ui/accordion.tsx +80 -0
  29. package/templates/next-shadcn/src/components/ui/alert-dialog.tsx +34 -22
  30. package/templates/next-shadcn/src/components/ui/avatar.tsx +7 -4
  31. package/templates/next-shadcn/src/components/ui/badge.tsx +15 -18
  32. package/templates/next-shadcn/src/components/ui/button.tsx +12 -3
  33. package/templates/next-shadcn/src/components/ui/calendar.tsx +1 -0
  34. package/templates/next-shadcn/src/components/ui/checkbox.tsx +6 -2
  35. package/templates/next-shadcn/src/components/ui/collapsible.tsx +33 -0
  36. package/templates/next-shadcn/src/components/ui/command.tsx +1 -2
  37. package/templates/next-shadcn/src/components/ui/dialog.tsx +34 -26
  38. package/templates/next-shadcn/src/components/ui/dropdown-menu.tsx +115 -114
  39. package/templates/next-shadcn/src/components/ui/hover-card.tsx +43 -0
  40. package/templates/next-shadcn/src/components/ui/input-group.tsx +2 -4
  41. package/templates/next-shadcn/src/components/ui/input.tsx +1 -2
  42. package/templates/next-shadcn/src/components/ui/label.tsx +6 -2
  43. package/templates/next-shadcn/src/components/ui/popover.tsx +27 -28
  44. package/templates/next-shadcn/src/components/ui/progress.tsx +11 -63
  45. package/templates/next-shadcn/src/components/ui/radio-group.tsx +43 -0
  46. package/templates/next-shadcn/src/components/ui/scroll-area.tsx +6 -6
  47. package/templates/next-shadcn/src/components/ui/select.tsx +55 -64
  48. package/templates/next-shadcn/src/components/ui/separator.tsx +6 -3
  49. package/templates/next-shadcn/src/components/ui/sheet.tsx +35 -26
  50. package/templates/next-shadcn/src/components/ui/slider.tsx +58 -0
  51. package/templates/next-shadcn/src/components/ui/switch.tsx +3 -2
  52. package/templates/next-shadcn/src/components/ui/table.tsx +115 -0
  53. package/templates/next-shadcn/src/components/ui/tabs.tsx +16 -8
  54. package/templates/next-shadcn/src/components/ui/toggle-group.tsx +89 -0
  55. package/templates/next-shadcn/src/components/ui/toggle.tsx +46 -0
  56. package/templates/next-shadcn/src/components/ui/tooltip.tsx +24 -33
  57. package/templates/next-shadcn/src/lib/utils.ts +6 -1
  58. package/ucode.js +8 -1
@@ -0,0 +1,283 @@
1
+ /**
2
+ * deploy.js — put the app online on Vercel.
3
+ *
4
+ * The user says "deploy it" (or types /deploy) and gets a live link. ucode
5
+ * picks a short project name that fits the app and is actually free, creates
6
+ * or reuses the Vercel project, copies the app's .env keys to it as encrypted
7
+ * variables, refuses to upload code with a secret written into it, and hands
8
+ * the upload and build to the Vercel CLI — which knows every framework's
9
+ * build settings better than anything written here would.
10
+ *
11
+ * The token comes from VERCEL_TOKEN (~/.ucode/.env). It is never printed:
12
+ * anything the CLI says is scrubbed of it before it reaches the screen.
13
+ */
14
+
15
+ import { spawn } from 'node:child_process';
16
+ import { promises as fs, existsSync } from 'node:fs';
17
+ import path from 'node:path';
18
+ import { ToolFailure } from '../core/failure.js';
19
+ import { ENV_FILE } from '../core/provider.js';
20
+ import { resolveIn, result } from './shared.js';
21
+ import { childEnv } from './shell.js';
22
+
23
+ const API = 'https://api.vercel.com';
24
+ const DEPLOY_TIMEOUT = 12 * 60_000;
25
+ let fetchImpl = (...a) => fetch(...a);
26
+
27
+ /** Tests hand in a fake fetch. */
28
+ export function setFetch(f) { fetchImpl = f ?? ((...a) => fetch(...a)); }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Names
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /** "Food IQ" → "food-iq": lowercase, a-z 0-9 and dashes, short, cut at a word. */
35
+ export function slugify(text, max = 20) {
36
+ let s = String(text ?? '').toLowerCase().replace(/^@[^/]+\//, '')
37
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
38
+ if (s.length > max) {
39
+ const cut = s.slice(0, max);
40
+ s = cut.includes('-') ? cut.slice(0, cut.lastIndexOf('-')) : cut;
41
+ }
42
+ return s || 'app';
43
+ }
44
+
45
+ /** Candidates in order of preference: the plain name first, then tidy variants. */
46
+ export function nameCandidates(base) {
47
+ const b = slugify(base);
48
+ const words = ['app', 'web', 'live', 'hq', 'hub'];
49
+ const rand = () => Math.random().toString(36).slice(2, 6);
50
+ return [b, ...words.map((w) => `${b}-${w}`), b.replace(/-/g, ''), `${b}-${rand()}`, `${b}-${rand()}`]
51
+ .filter((v, i, a) => v && a.indexOf(v) === i);
52
+ }
53
+
54
+ /**
55
+ * Is <name>.vercel.app free? Not a project of this account, and no one
56
+ * else's deployment answers at that address.
57
+ */
58
+ async function available(name, ctx) {
59
+ const mine = await ctx.api(`/v9/projects/${name}`);
60
+ if (mine.status === 200) return false;
61
+ try {
62
+ const r = await fetchImpl(`https://${name}.vercel.app`, { method: 'HEAD', redirect: 'manual', signal: AbortSignal.timeout(8000) });
63
+ return r.status === 404 && /DEPLOYMENT_NOT_FOUND|NOT_FOUND/i.test(r.headers.get('x-vercel-error') ?? 'NOT_FOUND');
64
+ } catch {
65
+ return true; // nothing answered at all — the name is unclaimed
66
+ }
67
+ }
68
+
69
+ export async function pickName(base, ctx) {
70
+ for (const candidate of nameCandidates(base)) {
71
+ if (await available(candidate, ctx)) return candidate;
72
+ }
73
+ throw new ToolFailure({
74
+ kind: 'no_name', attempted: 'choosing a project name',
75
+ failed: `Every name tried for "${base}" is taken.`,
76
+ fix: 'Call deploy again with a different name.',
77
+ });
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Secrets and env
82
+ // ---------------------------------------------------------------------------
83
+
84
+ const SECRET_PATTERNS = [
85
+ [/sk-or-v1-[0-9a-f]{20,}/, 'an API key (sk-or-v1-…)'],
86
+ [/\bsk-(?:proj-|ant-)?[A-Za-z0-9_-]{32,}/, 'an API key (sk-…)'],
87
+ [/\bvcp_[A-Za-z0-9]{20,}/, 'a Vercel token'],
88
+ [/\bAKIA[0-9A-Z]{16}\b/, 'an AWS access key'],
89
+ [/\bgh[pousr]_[A-Za-z0-9]{30,}/, 'a GitHub token'],
90
+ [/\bAIza[0-9A-Za-z_-]{35}\b/, 'a Google API key'],
91
+ ];
92
+ const SKIP_DIRS = new Set(['node_modules', '.next', '.git', '.vercel', 'dist', 'build', 'out', '.ucode', '.turbo']);
93
+ const SCAN_EXT = /\.(?:[cm]?[jt]sx?|html?|vue|svelte|astro|json)$/i;
94
+
95
+ /** Files with a secret written into them. `.env*` files are the right place, so they are skipped. */
96
+ export async function scanSecrets(dir) {
97
+ const found = [];
98
+ async function walk(d) {
99
+ for (const entry of await fs.readdir(d, { withFileTypes: true }).catch(() => [])) {
100
+ if (entry.isDirectory()) { if (!SKIP_DIRS.has(entry.name)) await walk(path.join(d, entry.name)); continue; }
101
+ if (entry.name.startsWith('.env') || entry.name === 'package-lock.json' || !SCAN_EXT.test(entry.name)) continue;
102
+ const file = path.join(d, entry.name);
103
+ const text = await fs.readFile(file, 'utf8').catch(() => '');
104
+ if (text.length > 1_000_000) continue;
105
+ const lines = text.split('\n');
106
+ for (let i = 0; i < lines.length; i++) {
107
+ const hit = SECRET_PATTERNS.find(([re]) => re.test(lines[i]));
108
+ if (hit) found.push({ file: path.relative(dir, file).split(path.sep).join('/'), line: i + 1, what: hit[1] });
109
+ }
110
+ }
111
+ }
112
+ await walk(dir);
113
+ return found;
114
+ }
115
+
116
+ /** KEY=value pairs from .env, .env.local, .env.production — later files win. */
117
+ export async function readEnv(dir) {
118
+ const vars = {};
119
+ for (const name of ['.env', '.env.local', '.env.production']) {
120
+ const text = await fs.readFile(path.join(dir, name), 'utf8').catch(() => '');
121
+ for (const line of text.split(/\r?\n/)) {
122
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
123
+ if (!m) continue;
124
+ vars[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2');
125
+ }
126
+ }
127
+ return vars;
128
+ }
129
+
130
+ function frameworkOf(pkg, dir) {
131
+ const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
132
+ if (deps.next) return 'nextjs';
133
+ if (deps.vite) return 'vite';
134
+ if (deps['react-scripts']) return 'create-react-app';
135
+ if (!pkg && existsSync(path.join(dir, 'index.html'))) return null;
136
+ return null;
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // The tool
141
+ // ---------------------------------------------------------------------------
142
+
143
+ export async function deploy({ folder = '.', name } = {}, { onOutput } = {}) {
144
+ const token = process.env.VERCEL_TOKEN;
145
+ if (!token) {
146
+ throw new ToolFailure({
147
+ kind: 'no_token', attempted: 'deploying to Vercel',
148
+ failed: 'There is no Vercel token yet.',
149
+ fix: `Tell the user: make one at vercel.com/account/tokens (Create Token), then add a line VERCEL_TOKEN=... to ${ENV_FILE} and restart ucode.`,
150
+ });
151
+ }
152
+ const say = (line) => onOutput?.([line]);
153
+ const where = resolveIn(folder, 'deploy', 'folder');
154
+ const dir = where.abs;
155
+ const pkg = JSON.parse(await fs.readFile(path.join(dir, 'package.json'), 'utf8').catch(() => 'null'));
156
+ if (!pkg && !existsSync(path.join(dir, 'index.html'))) {
157
+ throw new ToolFailure({
158
+ kind: 'not_an_app', attempted: `deploying ${where.show}`,
159
+ failed: `${where.show} has no package.json or index.html, so there is nothing to deploy.`,
160
+ fix: 'Pass the folder of the app itself, e.g. deploy({ folder: "food-iq" }).',
161
+ });
162
+ }
163
+
164
+ const scrub = (s) => String(s).split(token).join('***');
165
+ const api = async (pathname, { method = 'GET', body } = {}) => {
166
+ const url = new URL(API + pathname);
167
+ if (ctx.teamId) url.searchParams.set('teamId', ctx.teamId);
168
+ const r = await fetchImpl(url, {
169
+ method, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(20_000),
170
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
171
+ });
172
+ return { status: r.status, ok: r.ok, json: await r.json().catch(() => ({})) };
173
+ };
174
+ const ctx = { api, teamId: null };
175
+
176
+ say('Checking for secrets');
177
+ const leaks = await scanSecrets(dir);
178
+ if (leaks.length) {
179
+ const list = leaks.slice(0, 5).map((l) => `${l.file}:${l.line} (${l.what})`).join(', ');
180
+ throw new ToolFailure({
181
+ kind: 'secret_in_code', attempted: `deploying ${where.show}`,
182
+ failed: `The code has a secret written into it: ${list}. Once online, anyone could read it.`,
183
+ fix: 'Move the value to .env.local (e.g. OPENROUTER_API_KEY=...), read it with process.env in a ' +
184
+ 'server route (Next.js: app/api/.../route.ts), call that route from the page, then deploy again. ' +
185
+ 'ucode copies .env.local to Vercel for you.',
186
+ });
187
+ }
188
+
189
+ const me = await api('/v2/user');
190
+ if (!me.ok) {
191
+ throw new ToolFailure({
192
+ kind: 'bad_token', attempted: 'deploying to Vercel', failed: 'Vercel did not accept the token.',
193
+ fix: 'Tell the user to make a new token at vercel.com/account/tokens and put it in ~/.ucode/.env as VERCEL_TOKEN.',
194
+ });
195
+ }
196
+ ctx.teamId = me.json.user?.defaultTeamId ?? null;
197
+ const orgId = ctx.teamId ?? me.json.user?.id;
198
+
199
+ // Reuse the project this app was deployed to before, so the link stays the same.
200
+ const linkFile = path.join(dir, '.vercel', 'project.json');
201
+ let link = JSON.parse(await fs.readFile(linkFile, 'utf8').catch(() => 'null'));
202
+ if (link?.projectId) {
203
+ const still = await api(`/v9/projects/${link.projectId}`);
204
+ if (!still.ok) link = null;
205
+ else link.projectName = still.json.name;
206
+ }
207
+ if (!link?.projectId) {
208
+ say('Choosing a name');
209
+ const projectName = name ? slugify(name) : await pickName(pkg?.name || path.basename(dir), ctx);
210
+ const made = await api('/v11/projects', { method: 'POST', body: { name: projectName, framework: frameworkOf(pkg, dir) } });
211
+ if (!made.ok) {
212
+ throw new ToolFailure({
213
+ kind: 'vercel_error', attempted: `creating the Vercel project ${projectName}`,
214
+ failed: scrub(made.json?.error?.message ?? `HTTP ${made.status}`),
215
+ fix: 'Try again with a different name.',
216
+ });
217
+ }
218
+ link = { projectId: made.json.id, orgId, projectName };
219
+ await fs.mkdir(path.dirname(linkFile), { recursive: true });
220
+ await fs.writeFile(linkFile, JSON.stringify({ projectId: link.projectId, orgId }, null, 2));
221
+ const gi = path.join(dir, '.gitignore');
222
+ const ignore = await fs.readFile(gi, 'utf8').catch(() => '');
223
+ if (!/^\.vercel\/?$/m.test(ignore)) await fs.writeFile(gi, `${ignore.replace(/\n?$/, '\n')}.vercel\n`).catch(() => {});
224
+ }
225
+
226
+ const vars = await readEnv(dir);
227
+ const keys = Object.keys(vars);
228
+ if (keys.length) {
229
+ say(`Copying ${keys.length} key${keys.length === 1 ? '' : 's'} to Vercel`);
230
+ await api(`/v10/projects/${link.projectId}/env?upsert=true`, {
231
+ method: 'POST',
232
+ body: keys.map((key) => ({ key, value: vars[key], type: 'encrypted', target: ['production', 'preview'] })),
233
+ });
234
+ }
235
+
236
+ say('Uploading and building on Vercel');
237
+ const started = Date.now();
238
+ const { code, output } = await new Promise((resolve) => {
239
+ const args = ['--yes', 'vercel@latest', 'deploy', '--prod', '--yes', '--token', token];
240
+ const child = spawn(process.platform === 'win32' ? 'npx.cmd' : 'npx', args, {
241
+ cwd: dir, shell: process.platform === 'win32', windowsHide: true,
242
+ env: { ...childEnv(), VERCEL_ORG_ID: orgId, VERCEL_PROJECT_ID: link.projectId, VERCEL_TELEMETRY_DISABLED: '1' },
243
+ });
244
+ let output = '';
245
+ const take = (chunk) => {
246
+ const text = scrub(chunk).replace(/\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07/g, '');
247
+ output += text;
248
+ // Only lines that read as progress: no markup, no lone braces, no redraw leftovers.
249
+ const last = text.split(/\r?\n/).map((l) => l.replace(/\[[0-9;]*[A-Za-z]/g, '').trim())
250
+ .filter((l) => /[A-Za-z]{3}/.test(l) && !/^[<{}[\]]/.test(l)).at(-1);
251
+ if (last) say(last.replace(/^[▲✓>\s]+/, '').replace(/\s*\[\d+s\]$/, '').slice(0, 80));
252
+ };
253
+ child.stdout.on('data', take);
254
+ child.stderr.on('data', take);
255
+ const timer = setTimeout(() => child.kill(), DEPLOY_TIMEOUT);
256
+ child.on('error', (err) => { clearTimeout(timer); resolve({ code: -1, output: `${output}\n${err.message}` }); });
257
+ child.on('close', (c) => { clearTimeout(timer); resolve({ code: c, output }); });
258
+ });
259
+ const secs = Math.round((Date.now() - started) / 1000);
260
+
261
+ if (code !== 0) {
262
+ const tail = output.split(/\r?\n/).filter((l) => l.trim()).slice(-25).join('\n');
263
+ throw new ToolFailure({
264
+ kind: 'deploy_failed', attempted: `deploying ${where.show} to Vercel`,
265
+ failed: `The deploy failed after ${secs}s:\n${tail}`,
266
+ fix: 'The lines above name the problem — usually the same error `npm run build` shows locally. ' +
267
+ 'Fix it, check the build passes, then deploy again.',
268
+ });
269
+ }
270
+
271
+ const live = `https://${link.projectName}.vercel.app`;
272
+ const ok = await fetchImpl(live, { signal: AbortSignal.timeout(15_000) }).then((r) => r.status < 400).catch(() => false);
273
+ const url = ok ? live : (output.match(/https:\/\/[a-z0-9.-]+\.vercel\.app/g) ?? [live]).at(-1);
274
+ return result(
275
+ [
276
+ `Live at ${url}`,
277
+ `Deployed in ${secs}s as the Vercel project "${link.projectName}".`,
278
+ keys.length ? `Copied to Vercel as encrypted variables: ${keys.join(', ')}` : '',
279
+ 'Deploying again updates the same link.',
280
+ ].filter(Boolean).join('\n'),
281
+ `live · ${url} · ${secs}s`
282
+ );
283
+ }
@@ -15,6 +15,7 @@ import {
15
15
  changedRegion, renderDiff, renderNewFile, READ_LINES, MAX_FILE_OUTPUT,
16
16
  } from './shared.js';
17
17
  import { packageJsonWritten } from './shell.js';
18
+ import { parse as parseSource } from '@babel/parser';
18
19
 
19
20
  export async function readFile({ path: p, offset = 1, limit = READ_LINES }) {
20
21
  const target = resolveIn(p, 'read_file');
@@ -94,6 +95,71 @@ function written(target, content) {
94
95
  if (path.basename(target.abs) === 'package.json') packageJsonWritten(target.abs, content);
95
96
  }
96
97
 
98
+ const PARSEABLE = /\.(?:[cm]?[jt]sx?)$/i;
99
+
100
+ /**
101
+ * Does this source parse? Checked the moment a file is written.
102
+ *
103
+ * Measured on a real build: a JSX typo sat unnoticed until `npm run build`,
104
+ * which takes up to a minute, failed on it — then the fix, then another full
105
+ * build. Parsing the file takes milliseconds, so the error comes back in the
106
+ * same step that wrote it, with the line, while the model still has the file
107
+ * in front of it. Syntax only: types are checked at the end of the turn.
108
+ */
109
+ export function syntaxProblem(file, text) {
110
+ if (!PARSEABLE.test(file)) return null;
111
+ const ext = path.extname(file).toLowerCase();
112
+ const plugins = ext === '.tsx'
113
+ ? ['typescript', 'jsx']
114
+ : /^\.[cm]?ts$/.test(ext) ? ['typescript'] : ['jsx'];
115
+ const where = (loc) => (loc ? `line ${loc.line}, column ${loc.column + 1}` : 'somewhere in the file');
116
+ const clean = (m) => String(m).replace(/\s*\(\d+:\d+\)\s*$/, '');
117
+ try {
118
+ const ast = parseSource(text, {
119
+ sourceType: 'unambiguous',
120
+ plugins: [...plugins, 'decorators-legacy'],
121
+ errorRecovery: true,
122
+ allowReturnOutsideFunction: true,
123
+ allowAwaitOutsideFunction: true,
124
+ });
125
+ const first = ast.errors?.[0];
126
+ return first ? `${where(first.loc)}: ${clean(first.message)}` : null;
127
+ } catch (err) {
128
+ return `${where(err.loc)}: ${clean(err.message)}`;
129
+ }
130
+ }
131
+
132
+ /** The warning appended to a result when a file does not parse. */
133
+ const brokenNote = (show, problem) =>
134
+ `\n\n⚠ ${show} does not parse — ${problem}. Fix it now: the build will fail on it.`;
135
+
136
+ /** A file this short comes back whole after an edit; longer ones show the part around the change. */
137
+ const SHOW_WHOLE = 250;
138
+ const AROUND = 15;
139
+
140
+ /**
141
+ * The file as it stands after an edit, with the same line-number gutter as
142
+ * read_file.
143
+ *
144
+ * Measured on a real build: the model read the same component thirteen times
145
+ * in one turn, because an edit's result showed only the lines it replaced and
146
+ * the next edit needed the file as it now was. Sending the current text back
147
+ * with the edit costs the same tokens the re-read would have, and saves the
148
+ * round trip every time.
149
+ */
150
+ function nowReads(show, text, at, span) {
151
+ const lines = toLines(text);
152
+ const whole = lines.length <= SHOW_WHOLE;
153
+ const from = whole ? 1 : Math.max(1, at - AROUND);
154
+ const to = whole ? lines.length : Math.min(lines.length, at + span + AROUND);
155
+ const width = String(to).length;
156
+ const body = lines.slice(from - 1, to).map((l, i) => `${String(from + i).padStart(width)} | ${l}`).join('\n');
157
+ const heading = whole
158
+ ? `${show} now reads (all ${lines.length} lines`
159
+ : `${show} now reads, lines ${from}-${to} of ${lines.length}`;
160
+ return `\n\n${heading} — this is the current text, so there is no need to read it again):\n${body}`;
161
+ }
162
+
97
163
  /** At most this many files in one read_files call. */
98
164
  const MAX_BATCH = 20;
99
165
 
@@ -216,6 +282,7 @@ async function put(target, content, { diffMax = 16 } = {}) {
216
282
  existed,
217
283
  lineCount,
218
284
  diff,
285
+ problem: syntaxProblem(target.abs, content),
219
286
  line: `${existed ? 'Overwrote' : 'Created'} ${target.show} ` +
220
287
  `(${lineCount} lines, ${bytes(Buffer.byteLength(content))})`,
221
288
  };
@@ -234,7 +301,10 @@ export async function writeFile({ path: p, content }) {
234
301
  await guard(target, `write ${target.abs}`);
235
302
 
236
303
  const written = await put(target, content);
237
- const out = result(`${written.line}.`, `${written.existed ? 'overwrote' : 'created'} · ${written.lineCount} lines`);
304
+ const out = result(
305
+ `${written.line}.${written.problem ? brokenNote(target.show, written.problem) : ''}`,
306
+ `${written.existed ? 'overwrote' : 'created'} · ${written.lineCount} lines${written.problem ? ' · does not parse' : ''}`
307
+ );
238
308
  out.diff = written.diff;
239
309
  return out;
240
310
  }
@@ -258,6 +328,7 @@ export async function batchWrite({ files }) {
258
328
  const lines = [];
259
329
  const diff = [];
260
330
  let created = 0;
331
+ let broken = 0;
261
332
 
262
333
  for (const [index, file] of files.entries()) {
263
334
  const { path: p, content } = file ?? {};
@@ -277,13 +348,14 @@ export async function batchWrite({ files }) {
277
348
  // would bury the reply under three hundred lines of gutter.
278
349
  const written = await put(target, content, { diffMax: 6 });
279
350
  if (!written.existed) created++;
280
- lines.push(written.line);
351
+ lines.push(written.line + (written.problem ? brokenNote(target.show, written.problem) : ''));
352
+ if (written.problem) broken++;
281
353
  diff.push(`~${target.show}`, ...written.diff);
282
354
  }
283
355
 
284
356
  const out = result(
285
357
  lines.join('\n'),
286
- `${files.length} file${files.length === 1 ? '' : 's'} · ${created} new`
358
+ `${files.length} file${files.length === 1 ? '' : 's'} · ${created} new${broken ? ` · ${broken} do not parse` : ''}`
287
359
  );
288
360
  out.diff = diff;
289
361
  return out;
@@ -462,9 +534,13 @@ export async function editFile({ path: p, old_string, new_string }) {
462
534
  const change = delta === 0 ? 'same line count' : `${delta > 0 ? '+' : ''}${delta} lines`;
463
535
  const how = loose ? ', matched ignoring whitespace and re-indented to fit' : '';
464
536
 
537
+ const span = toLines(new_string).length;
465
538
  const out = result(
466
- `Replaced one occurrence in ${target.show} at line ${at} (${change}${how}).`,
467
- `1 change at line ${at} · ${change}${loose ? ' · whitespace-tolerant' : ''}`
539
+ `Replaced one occurrence in ${target.show} at line ${at} (${change}${how}).` +
540
+ (syntaxProblem(target.abs, text) ? brokenNote(target.show, syntaxProblem(target.abs, text)) : '') +
541
+ nowReads(target.show, text, at, span),
542
+ `1 change at line ${at} · ${change}${loose ? ' · whitespace-tolerant' : ''}${syntaxProblem(target.abs, text) ? ' · does not parse' : ''}`,
543
+ MAX_FILE_OUTPUT
468
544
  );
469
545
  // The replacement is diffed on its own and offset to where it landed, so
470
546
  // the gutter shows the file's line numbers rather than 1, 2, 3.
@@ -531,8 +607,11 @@ export async function multiEdit({ path: p, edits }) {
531
607
  const change = delta === 0 ? 'same line count' : `${delta > 0 ? '+' : ''}${delta} lines`;
532
608
 
533
609
  const out = result(
534
- `Applied ${edits.length} edits to ${target.show} (${change}).`,
535
- `${edits.length} edits · ${change}`
610
+ `Applied ${edits.length} edits to ${target.show} (${change}).` +
611
+ (syntaxProblem(target.abs, text) ? brokenNote(target.show, syntaxProblem(target.abs, text)) : '') +
612
+ nowReads(target.show, text, 1, toLines(text).length),
613
+ `${edits.length} edits · ${change}`,
614
+ MAX_FILE_OUTPUT
536
615
  );
537
616
  out.diff = diff;
538
617
  return out;
@@ -615,7 +694,11 @@ export async function editFiles({ files }) {
615
694
 
616
695
  const edits = planned.reduce((n, p) => n + p.count, 0);
617
696
  const out = result(
618
- planned.map((p) => `Edited ${p.target.show} (${p.count} change${p.count === 1 ? '' : 's'})`).join('\n'),
697
+ planned.map((p) => {
698
+ const problem = syntaxProblem(p.target.abs, p.text);
699
+ return `Edited ${p.target.show} (${p.count} change${p.count === 1 ? '' : 's'})` +
700
+ (problem ? brokenNote(p.target.show, problem) : '');
701
+ }).join('\n'),
619
702
  `${planned.length} files · ${edits} edits`
620
703
  );
621
704
  out.diff = planned.flatMap((p) => p.diff);
@@ -10,6 +10,7 @@ import { runCommand, runCommands } from './shell.js';
10
10
  import { webSearch } from './web.js';
11
11
  import { lookAtApp } from './browser.js';
12
12
  import { createApp } from './scaffold.js';
13
+ import { deploy } from './deploy.js';
13
14
  import { clip, READ_LINES } from './shared.js';
14
15
 
15
16
  export { setRoot, setConfirm, getRoot } from './shared.js';
@@ -24,8 +25,8 @@ export const tools = [
24
25
  description:
25
26
  'Start a new Next.js + shadcn/ui app from the ready-made ucode starter. This is how every ' +
26
27
  'Next.js app begins - never run create-next-app or shadcn init. It copies a project that ' +
27
- 'is already known to build (Next.js 16, TypeScript, Tailwind 4, shadcn with 25 common ' +
28
- 'components, light/dark mode, toasts, a considered theme) into a new empty folder, and ' +
28
+ 'is already known to build (Next.js 16, TypeScript, Tailwind 4, shadcn with 33 common ' +
29
+ 'components, light/dark mode, toasts, a design preset of colours and fonts) into a new empty folder, and ' +
29
30
  'starts installing its packages in the background so you can write components at once. ' +
30
31
  'The result lists everything included.',
31
32
  parameters: {
@@ -34,14 +35,43 @@ export const tools = [
34
35
  folder: str('A new, empty folder for the app, relative to the project root, e.g. "stride".'),
35
36
  name: str('The display name of the app, e.g. "Stride".'),
36
37
  description: str('One line about the app, used in the page metadata.'),
38
+ design: {
39
+ type: 'string',
40
+ enum: ['ocean', 'grove', 'sunset', 'graphite', 'violet', 'citrus'],
41
+ description:
42
+ 'The look: colours and fonts, light and dark. Pick the one that fits the app. ' +
43
+ 'ocean - calm blue, for dashboards, finance, productivity (default). ' +
44
+ 'grove - fresh green, for health, habits, food, nature. ' +
45
+ 'sunset - warm coral with a serif, for travel, recipes, journaling, lifestyle. ' +
46
+ 'graphite - monochrome and crisp, for developer tools, docs, portfolios. ' +
47
+ 'violet - vivid violet, for AI tools, creative apps, music, learning. ' +
48
+ 'citrus - bright lime and bold, for games, sport, kids, social.',
49
+ },
37
50
  },
38
51
  required: ['folder', 'name'],
39
52
  },
40
53
  },
54
+ {
55
+ name: 'deploy',
56
+ description:
57
+ 'Put an app online on Vercel and get its live link - use it when the user asks to deploy, ' +
58
+ 'publish, host or share the app. ucode picks a short free project name, copies the app\'s ' +
59
+ '.env keys to Vercel as encrypted variables, refuses code with a secret written into it ' +
60
+ '(move it to .env.local and a server route, then deploy again), and builds on Vercel. ' +
61
+ 'Run the local build first so errors show up here. Deploying again updates the same link.',
62
+ parameters: {
63
+ type: 'object',
64
+ properties: {
65
+ folder: str('The app folder, relative to the project root, e.g. "food-iq". Defaults to ".".'),
66
+ name: str('Optional: a project name to use instead of the one ucode would choose.'),
67
+ },
68
+ required: [],
69
+ },
70
+ },
41
71
  {
42
72
  name: 'read_file',
43
73
  description:
44
- 'Read a text file. Comes back as numbered lines — the numbers are for you to ' +
74
+ 'Read one text file - for two or more, use read_files instead. Comes back as numbered lines — the numbers are for you to ' +
45
75
  'refer to and must never appear in an edit_file argument. Long files arrive in ' +
46
76
  'pages; pass offset to keep going.',
47
77
  parameters: {
@@ -120,7 +150,8 @@ export const tools = [
120
150
  'Replace one exact piece of text in a file. old_string must match the file ' +
121
151
  'character for character, including indentation, and must occur exactly once — ' +
122
152
  'the edit is refused on zero matches and on two. This is the normal way to ' +
123
- 'change existing code.',
153
+ 'change existing code. The result shows the file as it now stands, so do not ' +
154
+ 'read it again afterwards.',
124
155
  parameters: {
125
156
  type: 'object',
126
157
  properties: {
@@ -294,9 +325,10 @@ export const tools = [
294
325
  'Open the running app in a real browser at a phone width (375px) and a desktop width ' +
295
326
  '(1440px) and report what a person would run into: console errors, failed requests, ' +
296
327
  'content that spills off the side of the screen, broken images, unlabeled buttons and ' +
297
- 'fields - plus a designer-style review of the screenshots. Use it once the dev server ' +
298
- 'is ready, and again after visual changes, then fix what it reports. Screenshots are ' +
299
- 'saved under .ucode/screenshots.',
328
+ 'fields. The first look at an app also brings a designer-style review of the ' +
329
+ 'screenshots; later looks re-run only the fast checks. Use it once the dev server is ' +
330
+ 'ready, fix what it reports, then look once more to confirm. Screenshots are saved ' +
331
+ 'under .ucode/screenshots.',
300
332
  parameters: {
301
333
  type: 'object',
302
334
  properties: {
@@ -306,7 +338,6 @@ export const tools = [
306
338
  description: 'Pages to open, e.g. ["/", "/settings"]. Defaults to ["/"]. Up to 4.',
307
339
  items: { type: 'string' },
308
340
  },
309
- review: bool('Include the visual design review of the screenshots. Defaults to true.'),
310
341
  },
311
342
  required: ['url'],
312
343
  },
@@ -345,11 +376,12 @@ const run = {
345
376
  web_search: webSearch,
346
377
  look_at_app: lookAtApp,
347
378
  create_app: createApp,
379
+ deploy,
348
380
  };
349
381
 
350
382
  /** Tools that change the project or execute code. */
351
383
  export const MUTATING = new Set([
352
- 'write_file', 'batch_write', 'edit_file', 'multi_edit', 'edit_files', 'run_command', 'run_commands',
384
+ 'write_file', 'batch_write', 'edit_file', 'multi_edit', 'edit_files', 'run_command', 'run_commands', 'deploy',
353
385
  ]);
354
386
 
355
387
  /** Tools with no side effects, so several may run at the same time. */
@@ -358,7 +390,7 @@ export const PARALLEL_SAFE = new Set(['read_file', 'read_files', 'list_dir', 'gl
358
390
  /** Tools withheld in plan mode. Withholding beats asking a model not to. */
359
391
  export const WRITES = new Set([
360
392
  'write_file', 'batch_write', 'edit_file', 'multi_edit', 'edit_files', 'run_command', 'run_commands',
361
- 'delegate', 'create_app',
393
+ 'delegate', 'create_app', 'deploy',
362
394
  ]);
363
395
 
364
396
  /** Tools that change files on disk, which parallel workers take turns at. */
@@ -481,6 +513,8 @@ export function describe(name, args = {}) {
481
513
  return `Running ${clip(args.command, 70)}${args.background ? ' in the background' : ''}`;
482
514
  case 'run_commands':
483
515
  return `Running ${args.commands?.length ?? 0} commands together`;
516
+ case 'deploy':
517
+ return `Deploying ${clip(args.folder || '.', 30)} to Vercel`;
484
518
  case 'create_app':
485
519
  return `Creating ${clip(args.name || args.folder, 30)} from the Next.js starter`;
486
520
  case 'look_at_app':
@@ -64,7 +64,65 @@ async function copyTree(from, to, fill) {
64
64
  * @param {string} [o.template]
65
65
  * @param {boolean} [o.install] start the background install (tests turn it off)
66
66
  */
67
- export async function createApp({ folder, name, description, template = 'next-shadcn', install = true }) {
67
+ /**
68
+ * Give the new app its look: one of the hand-picked presets in the starter's
69
+ * presets/ folder — a full light and dark palette and a font — written into
70
+ * globals.css and layout.tsx. Apps stop looking like the same default blue.
71
+ * Returns the preset used, or null when the starter has none.
72
+ */
73
+ export async function applyDesign(appDir, design) {
74
+ const dir = path.join(appDir, 'presets');
75
+ const names = (await fs.readdir(dir).catch(() => [])).filter((f) => f.endsWith('.json'));
76
+ if (!names.length) return null;
77
+ const presets = await Promise.all(names.map(async (f) => JSON.parse(await fs.readFile(path.join(dir, f), 'utf8'))));
78
+ await fs.rm(dir, { recursive: true, force: true }); // the app needs the result, not the catalogue
79
+ const preset = presets.find((p) => p.name === design) ?? presets.find((p) => p.default) ?? presets[0];
80
+
81
+ const cssFile = path.join(appDir, 'src', 'app', 'globals.css');
82
+ let css = await fs.readFile(cssFile, 'utf8').catch(() => null);
83
+ if (css !== null) {
84
+ const retint = (selector, tokens) => {
85
+ const block = new RegExp(`(${selector}\\s*\\{)([\\s\\S]*?)(\\n\\})`);
86
+ css = css.replace(block, (all, open, body, close) => {
87
+ const seen = new Set();
88
+ let next = body.replace(/(\n\s*)--([\w-]+):\s*[^;]+;/g, (line, lead, key) => {
89
+ if (!(key in tokens)) return line;
90
+ seen.add(key);
91
+ return `${lead}--${key}: ${tokens[key]};`;
92
+ });
93
+ for (const [key, value] of Object.entries(tokens)) if (!seen.has(key)) next += `\n --${key}: ${value};`;
94
+ return open + next + close;
95
+ });
96
+ };
97
+ retint(':root', { radius: preset.radius, ...preset.light });
98
+ retint('\\.dark', preset.dark);
99
+ await fs.writeFile(cssFile, css);
100
+ }
101
+
102
+ const sans = preset.fonts?.sans;
103
+ const layoutFile = path.join(appDir, 'src', 'app', 'layout.tsx');
104
+ if (sans && sans !== 'Geist') {
105
+ const id = sans.replace(/\s+/g, '_');
106
+ const layout = await fs.readFile(layoutFile, 'utf8').catch(() => null);
107
+ if (layout !== null) {
108
+ await fs.writeFile(layoutFile, layout
109
+ .replace('import { Geist, Geist_Mono } from "next/font/google";', `import { ${id}, Geist_Mono } from "next/font/google";`)
110
+ .replace('const sans = Geist({', `const sans = ${id}({`));
111
+ }
112
+ }
113
+
114
+ const guide = path.join(appDir, 'TEMPLATE.md');
115
+ const text = await fs.readFile(guide, 'utf8').catch(() => null);
116
+ if (text !== null) {
117
+ await fs.writeFile(guide, `${text.trimEnd()}\n\n## Design\n\nThis app uses the **${preset.name}** preset — ` +
118
+ `${preset.summary}. Font: ${sans ?? 'Geist'}. The palette lives in globals.css (light and dark): ` +
119
+ 'build with the tokens (bg-primary, text-muted-foreground, border, ...) rather than raw colours, ' +
120
+ 'so every screen stays in one look.\n');
121
+ }
122
+ return preset;
123
+ }
124
+
125
+ export async function createApp({ folder, name, description, template = 'next-shadcn', design, install = true }) {
68
126
  if (!TEMPLATE_NAMES.includes(template)) {
69
127
  throw new ToolFailure({
70
128
  kind: 'bad_args',
@@ -110,6 +168,7 @@ export async function createApp({ folder, name, description, template = 'next-sh
110
168
 
111
169
  const files = await copyTree(path.join(TEMPLATES, template), target.abs, fill);
112
170
  await fs.mkdir(path.join(target.abs, 'public'), { recursive: true });
171
+ const look = await applyDesign(target.abs, design);
113
172
 
114
173
  if (install) {
115
174
  const pkg = path.join(target.abs, 'package.json');
@@ -120,6 +179,7 @@ export async function createApp({ folder, name, description, template = 'next-sh
120
179
 
121
180
  return result(
122
181
  `Created ${target.show} from the ${template} starter — ${files.length} files, already known to build.\n` +
182
+ (look ? `Design: the ${look.name} preset (${look.summary}), font ${look.fonts?.sans ?? 'Geist'}.\n` : '') +
123
183
  (install
124
184
  ? `Its packages are installing in the background right now. Keep writing: any command you run in ` +
125
185
  `${target.show} waits for that install first, so there is no need to run npm install.\n`