ucode-agent 1.3.0 → 1.5.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 +42 -12
- package/package.json +5 -3
- package/skills/build-app/SKILL.md +23 -3
- package/skills/ui-ux/SKILL.md +5 -1
- package/src/core/loop.js +181 -20
- package/src/core/provider.js +40 -12
- package/src/core/updater.js +95 -0
- package/src/tools/browser.js +258 -0
- package/src/tools/index.js +51 -1
- package/src/tools/scaffold.js +130 -0
- package/src/tools/shell.js +8 -1
- package/src/ui/screen.js +20 -6
- package/templates/next-shadcn/AGENTS.md +9 -0
- package/templates/next-shadcn/README.md +9 -0
- package/templates/next-shadcn/TEMPLATE.md +39 -0
- package/templates/next-shadcn/_gitignore +41 -0
- package/templates/next-shadcn/_package-lock.json +10278 -0
- package/templates/next-shadcn/components.json +25 -0
- package/templates/next-shadcn/eslint.config.mjs +18 -0
- package/templates/next-shadcn/next-env.d.ts +5 -0
- package/templates/next-shadcn/next.config.ts +7 -0
- package/templates/next-shadcn/package.json +37 -0
- package/templates/next-shadcn/postcss.config.mjs +7 -0
- package/templates/next-shadcn/src/app/favicon.ico +0 -0
- package/templates/next-shadcn/src/app/globals.css +155 -0
- package/templates/next-shadcn/src/app/layout.tsx +42 -0
- package/templates/next-shadcn/src/app/page.tsx +14 -0
- package/templates/next-shadcn/src/components/theme-provider.tsx +7 -0
- package/templates/next-shadcn/src/components/theme-toggle.tsx +27 -0
- package/templates/next-shadcn/src/components/ui/alert-dialog.tsx +187 -0
- package/templates/next-shadcn/src/components/ui/avatar.tsx +108 -0
- package/templates/next-shadcn/src/components/ui/badge.tsx +51 -0
- package/templates/next-shadcn/src/components/ui/button.tsx +57 -0
- package/templates/next-shadcn/src/components/ui/calendar.tsx +221 -0
- package/templates/next-shadcn/src/components/ui/card.tsx +102 -0
- package/templates/next-shadcn/src/components/ui/checkbox.tsx +28 -0
- package/templates/next-shadcn/src/components/ui/command.tsx +196 -0
- package/templates/next-shadcn/src/components/ui/dialog.tsx +160 -0
- package/templates/next-shadcn/src/components/ui/dropdown-menu.tsx +267 -0
- package/templates/next-shadcn/src/components/ui/input-group.tsx +158 -0
- package/templates/next-shadcn/src/components/ui/input.tsx +19 -0
- package/templates/next-shadcn/src/components/ui/label.tsx +19 -0
- package/templates/next-shadcn/src/components/ui/popover.tsx +89 -0
- package/templates/next-shadcn/src/components/ui/progress.tsx +82 -0
- package/templates/next-shadcn/src/components/ui/scroll-area.tsx +54 -0
- package/templates/next-shadcn/src/components/ui/select.tsx +200 -0
- package/templates/next-shadcn/src/components/ui/separator.tsx +24 -0
- package/templates/next-shadcn/src/components/ui/sheet.tsx +138 -0
- package/templates/next-shadcn/src/components/ui/skeleton.tsx +13 -0
- package/templates/next-shadcn/src/components/ui/sonner.tsx +49 -0
- package/templates/next-shadcn/src/components/ui/switch.tsx +31 -0
- package/templates/next-shadcn/src/components/ui/tabs.tsx +81 -0
- package/templates/next-shadcn/src/components/ui/textarea.tsx +17 -0
- package/templates/next-shadcn/src/components/ui/tooltip.tsx +65 -0
- package/templates/next-shadcn/src/lib/utils.ts +1 -0
- package/templates/next-shadcn/tsconfig.json +34 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* updater.js — staying current without anyone running npm by hand.
|
|
3
|
+
*
|
|
4
|
+
* On every launch ucode asks the registry, in the background, whether a newer
|
|
5
|
+
* version exists. If one does, it installs it globally, detached, while you
|
|
6
|
+
* work. The version you are running carries on untouched; the next launch is
|
|
7
|
+
* the new one. Nothing about starting ucode waits on any of this.
|
|
8
|
+
*
|
|
9
|
+
* It stays out of the way in three cases: a development checkout (updating
|
|
10
|
+
* would overwrite the `npm link` that points at your working copy), when
|
|
11
|
+
* UCODE_NO_UPDATE is set, and when another ucode is already updating.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { existsSync, promises as fs } from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { VERSION } from './version.js';
|
|
20
|
+
|
|
21
|
+
const PACKAGE = 'ucode-agent';
|
|
22
|
+
const PACKAGE_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
23
|
+
const HOME = path.join(os.homedir(), '.ucode');
|
|
24
|
+
const LOCK = path.join(HOME, 'update.lock');
|
|
25
|
+
const LOG = path.join(HOME, 'update.log');
|
|
26
|
+
const LOCK_TTL = 10 * 60_000;
|
|
27
|
+
|
|
28
|
+
/** "1.10.0" > "1.9.3" — numeric, part by part. */
|
|
29
|
+
export function newer(a, b) {
|
|
30
|
+
const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
|
|
31
|
+
const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
|
|
32
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
33
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isDevCheckout() {
|
|
39
|
+
return existsSync(path.join(PACKAGE_ROOT, '.git'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function latestVersion() {
|
|
43
|
+
const res = await fetch(`https://registry.npmjs.org/${PACKAGE}/latest`, {
|
|
44
|
+
signal: AbortSignal.timeout(5_000),
|
|
45
|
+
headers: { accept: 'application/json' },
|
|
46
|
+
});
|
|
47
|
+
if (!res.ok) return null;
|
|
48
|
+
return (await res.json())?.version ?? null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function takeLock() {
|
|
52
|
+
try {
|
|
53
|
+
const stat = await fs.stat(LOCK);
|
|
54
|
+
if (Date.now() - stat.mtimeMs < LOCK_TTL) return false; // someone else is on it
|
|
55
|
+
} catch { /* no lock — good */ }
|
|
56
|
+
await fs.mkdir(HOME, { recursive: true });
|
|
57
|
+
await fs.writeFile(LOCK, String(process.pid));
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Check, and install if there is something newer.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} o
|
|
65
|
+
* @param {(v: string) => void} [o.onUpdated] called when the install finishes
|
|
66
|
+
*/
|
|
67
|
+
export async function autoUpdate({ onUpdated } = {}) {
|
|
68
|
+
try {
|
|
69
|
+
if (process.env.UCODE_NO_UPDATE || isDevCheckout() || !VERSION) return;
|
|
70
|
+
const latest = await latestVersion();
|
|
71
|
+
if (!latest || !newer(latest, VERSION)) return;
|
|
72
|
+
if (!(await takeLock())) return;
|
|
73
|
+
|
|
74
|
+
const log = await fs.open(LOG, 'w');
|
|
75
|
+
const child = spawn(`npm install -g ${PACKAGE}@${latest} --no-audit --no-fund`, {
|
|
76
|
+
shell: true,
|
|
77
|
+
detached: true,
|
|
78
|
+
windowsHide: true,
|
|
79
|
+
stdio: ['ignore', log.fd, log.fd],
|
|
80
|
+
});
|
|
81
|
+
await log.close();
|
|
82
|
+
child.unref();
|
|
83
|
+
|
|
84
|
+
child.on('exit', async (code) => {
|
|
85
|
+
await fs.rm(LOCK, { force: true }).catch(() => {});
|
|
86
|
+
if (code === 0) onUpdated?.(latest);
|
|
87
|
+
});
|
|
88
|
+
child.on('error', async () => {
|
|
89
|
+
await fs.rm(LOCK, { force: true }).catch(() => {});
|
|
90
|
+
});
|
|
91
|
+
} catch {
|
|
92
|
+
// An update check must never be the reason ucode misbehaves. Offline,
|
|
93
|
+
// registry down, no permission to install globally: all silently skipped.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser.js — looking at the app the way a person would.
|
|
3
|
+
*
|
|
4
|
+
* A build that passes and a page that works are different claims. This opens
|
|
5
|
+
* the running app in a real browser at a phone width and a desktop width, and
|
|
6
|
+
* reports what a person would run into: errors in the console, requests that
|
|
7
|
+
* failed, a layout that spills off the side of a phone, broken images,
|
|
8
|
+
* controls with no name. It saves a screenshot of each, and has the one model
|
|
9
|
+
* in the set that can see — Nemotron Nano Omni — review them as a designer
|
|
10
|
+
* would. The model building the app then has something concrete to fix.
|
|
11
|
+
*
|
|
12
|
+
* It drives the browser already on the machine (Edge or Chrome) through
|
|
13
|
+
* playwright-core, so there is no separate 150 MB browser download.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { promises as fs } from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { ToolFailure } from '../core/failure.js';
|
|
19
|
+
import { ask } from '../core/provider.js';
|
|
20
|
+
import { getRoot, result } from './shared.js';
|
|
21
|
+
|
|
22
|
+
const VISION_MODEL = 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free';
|
|
23
|
+
const WIDTHS = [
|
|
24
|
+
{ name: 'phone', width: 375, height: 812 },
|
|
25
|
+
{ name: 'desktop', width: 1440, height: 900 },
|
|
26
|
+
];
|
|
27
|
+
const LOCAL = /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/|$)/i;
|
|
28
|
+
|
|
29
|
+
let browserPromise = null;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One browser for the whole session, started on first use. The installed
|
|
33
|
+
* Edge or Chrome is tried first; Playwright's own Chromium only if it happens
|
|
34
|
+
* to be installed.
|
|
35
|
+
*/
|
|
36
|
+
async function browser() {
|
|
37
|
+
if (browserPromise) return browserPromise;
|
|
38
|
+
browserPromise = (async () => {
|
|
39
|
+
let chromium;
|
|
40
|
+
try {
|
|
41
|
+
({ chromium } = await import('playwright-core'));
|
|
42
|
+
} catch (err) {
|
|
43
|
+
throw new ToolFailure({
|
|
44
|
+
kind: 'no_playwright',
|
|
45
|
+
attempted: 'starting a browser',
|
|
46
|
+
failed: `playwright-core could not be loaded: ${err.message}`,
|
|
47
|
+
fix: 'Reinstall ucode (npm install -g ucode-agent). Carry on without looking at the app, and say so.',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const tried = [];
|
|
51
|
+
for (const channel of ['msedge', 'chrome', undefined]) {
|
|
52
|
+
try {
|
|
53
|
+
return await chromium.launch({ channel, headless: true });
|
|
54
|
+
} catch (err) {
|
|
55
|
+
tried.push(`${channel ?? 'bundled chromium'}: ${String(err.message).split('\n')[0]}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
throw new ToolFailure({
|
|
59
|
+
kind: 'no_browser',
|
|
60
|
+
attempted: 'starting a browser',
|
|
61
|
+
failed: `No browser could be started. Tried ${tried.join('; ')}.`,
|
|
62
|
+
fix: 'Install Google Chrome or Microsoft Edge. Carry on without looking at the app, and say so.',
|
|
63
|
+
});
|
|
64
|
+
})();
|
|
65
|
+
browserPromise.catch(() => { browserPromise = null; });
|
|
66
|
+
return browserPromise;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Close the shared browser, if one was started. Called when ucode exits. */
|
|
70
|
+
export async function closeBrowser() {
|
|
71
|
+
if (!browserPromise) return;
|
|
72
|
+
try { await (await browserPromise).close(); } catch { /* already gone */ }
|
|
73
|
+
browserPromise = null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Layout and accessibility checks run inside the page. */
|
|
77
|
+
function inspect() {
|
|
78
|
+
const vw = window.innerWidth;
|
|
79
|
+
const describeEl = (el) => {
|
|
80
|
+
const id = el.id ? `#${el.id}` : '';
|
|
81
|
+
const cls = typeof el.className === 'string' && el.className.trim()
|
|
82
|
+
? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}` : '';
|
|
83
|
+
const text = (el.innerText || el.getAttribute('aria-label') || '').trim().replace(/\s+/g, ' ').slice(0, 40);
|
|
84
|
+
return `<${el.tagName.toLowerCase()}${id}${cls}>${text ? ` "${text}"` : ''}`;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const overflow = document.documentElement.scrollWidth - vw;
|
|
88
|
+
const wide = [];
|
|
89
|
+
if (overflow > 1) {
|
|
90
|
+
for (const el of document.querySelectorAll('body *')) {
|
|
91
|
+
const r = el.getBoundingClientRect();
|
|
92
|
+
if (r.width > 0 && r.right > vw + 1 && getComputedStyle(el).position !== 'fixed') {
|
|
93
|
+
wide.push(`${describeEl(el)} reaches ${Math.round(r.right)}px`);
|
|
94
|
+
if (wide.length >= 5) break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const broken = [...document.images].filter((i) => i.complete && i.naturalWidth === 0).map((i) => i.src.slice(0, 80));
|
|
100
|
+
const noAlt = [...document.images].filter((i) => !i.hasAttribute('alt')).length;
|
|
101
|
+
const unnamed = [...document.querySelectorAll('button, a[href], [role="button"]')]
|
|
102
|
+
.filter((el) => !(el.innerText || '').trim() && !el.getAttribute('aria-label') && !el.getAttribute('title')
|
|
103
|
+
&& !el.querySelector('[aria-label], title, img[alt]:not([alt=""])'))
|
|
104
|
+
.slice(0, 5).map(describeEl);
|
|
105
|
+
const inputsNoLabel = [...document.querySelectorAll('input:not([type="hidden"]), textarea, select')]
|
|
106
|
+
.filter((el) => !(el.id && document.querySelector(`label[for="${el.id}"]`)) && !el.closest('label')
|
|
107
|
+
&& !el.getAttribute('aria-label') && !el.getAttribute('aria-labelledby'))
|
|
108
|
+
.length;
|
|
109
|
+
const tiny = vw < 600
|
|
110
|
+
? [...document.querySelectorAll('button, a[href], [role="button"], input, select')]
|
|
111
|
+
.filter((el) => { const r = el.getBoundingClientRect(); return r.width > 0 && (r.height < 32 || r.width < 32); })
|
|
112
|
+
.length
|
|
113
|
+
: 0;
|
|
114
|
+
const smallText = [...document.querySelectorAll('p, li, span, a, button, label, td')]
|
|
115
|
+
.filter((el) => el.childElementCount === 0 && (el.innerText || '').trim() && parseFloat(getComputedStyle(el).fontSize) < 12)
|
|
116
|
+
.length;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
title: document.title,
|
|
120
|
+
overflow: overflow > 1 ? Math.round(overflow) : 0,
|
|
121
|
+
wide, broken, noAlt, unnamed, inputsNoLabel, tiny, smallText,
|
|
122
|
+
empty: !(document.body.innerText || '').trim(),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const safeName = (p) => (p === '/' ? 'home' : p.replace(/^\/+|\/+$/g, '').replace(/[^\w-]+/g, '_')) || 'page';
|
|
127
|
+
|
|
128
|
+
async function review(shots) {
|
|
129
|
+
const request = [
|
|
130
|
+
{
|
|
131
|
+
role: 'system',
|
|
132
|
+
content:
|
|
133
|
+
'You are a senior product designer reviewing screenshots of a web app, one at a phone width ' +
|
|
134
|
+
'and one at desktop width. List the concrete visual problems a user would notice, most ' +
|
|
135
|
+
'important first: broken or cramped layout, overflow, misalignment, weak hierarchy (is the ' +
|
|
136
|
+
'most important thing the most prominent?), inconsistent spacing, low contrast, default-looking ' +
|
|
137
|
+
'components, awkward empty states, text that is too small. For each: where it is, what is wrong, ' +
|
|
138
|
+
'and the specific fix. At most 8 points, one or two lines each. If it genuinely looks polished, ' +
|
|
139
|
+
'say so in one line and name the one thing that would improve it most. No preamble.',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
role: 'user',
|
|
143
|
+
content: shots.map((s) => `${s.label}`).join(' and ') + '.',
|
|
144
|
+
images: shots.map((s) => s.dataUrl),
|
|
145
|
+
},
|
|
146
|
+
];
|
|
147
|
+
const reply = await ask(request, [], { model: VISION_MODEL, temperature: 0.2, maxOutputTokens: 900 });
|
|
148
|
+
return reply.text.trim();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function lookAtApp({ url, paths = ['/'], review: wantReview = true }) {
|
|
152
|
+
const base = String(url ?? '').trim().replace(/\/+$/, '');
|
|
153
|
+
if (!LOCAL.test(`${base}/`)) {
|
|
154
|
+
throw new ToolFailure({
|
|
155
|
+
kind: 'bad_args',
|
|
156
|
+
attempted: 'looking at the app',
|
|
157
|
+
failed: `"${url}" is not a local address. This only opens apps running on this machine.`,
|
|
158
|
+
fix: 'Pass the URL the dev server reported, e.g. http://localhost:3000',
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const pages = (Array.isArray(paths) && paths.length ? paths : ['/'])
|
|
163
|
+
.map((p) => `/${String(p).trim().replace(/^\/+/, '')}`)
|
|
164
|
+
.slice(0, 4);
|
|
165
|
+
|
|
166
|
+
const shotsDir = path.join(getRoot(), '.ucode', 'screenshots');
|
|
167
|
+
await fs.mkdir(shotsDir, { recursive: true });
|
|
168
|
+
|
|
169
|
+
const b = await browser();
|
|
170
|
+
const sections = [];
|
|
171
|
+
const toReview = [];
|
|
172
|
+
let problems = 0;
|
|
173
|
+
|
|
174
|
+
for (const pagePath of pages) {
|
|
175
|
+
for (const size of WIDTHS) {
|
|
176
|
+
const context = await b.newContext({ viewport: { width: size.width, height: size.height }, deviceScaleFactor: 1 });
|
|
177
|
+
const page = await context.newPage();
|
|
178
|
+
const errors = [];
|
|
179
|
+
const failed = [];
|
|
180
|
+
page.on('console', (m) => {
|
|
181
|
+
if (m.type() === 'error' && !/devtools|download the react/i.test(m.text())) errors.push(m.text().slice(0, 200));
|
|
182
|
+
});
|
|
183
|
+
page.on('pageerror', (e) => errors.push(`uncaught: ${String(e.message).slice(0, 200)}`));
|
|
184
|
+
page.on('requestfailed', (r) => failed.push(`${r.method()} ${r.url().slice(0, 100)} — ${r.failure()?.errorText ?? 'failed'}`));
|
|
185
|
+
page.on('response', (r) => { if (r.status() >= 400) failed.push(`${r.status()} ${r.url().slice(0, 100)}`); });
|
|
186
|
+
|
|
187
|
+
const target = `${base}${pagePath}`;
|
|
188
|
+
let loadError = null;
|
|
189
|
+
try {
|
|
190
|
+
// A dev server compiles a page on its first request, which can take a
|
|
191
|
+
// while; networkidle then waits for the page's own data to arrive.
|
|
192
|
+
await page.goto(target, { waitUntil: 'networkidle', timeout: 60_000 });
|
|
193
|
+
} catch (err) {
|
|
194
|
+
try { await page.goto(target, { waitUntil: 'load', timeout: 30_000 }); }
|
|
195
|
+
catch (err2) { loadError = String(err2.message).split('\n')[0]; }
|
|
196
|
+
}
|
|
197
|
+
await page.waitForTimeout(600); // let entrance animations settle
|
|
198
|
+
|
|
199
|
+
const file = path.join(shotsDir, `${safeName(pagePath)}-${size.name}.jpg`);
|
|
200
|
+
let facts = null;
|
|
201
|
+
if (!loadError) {
|
|
202
|
+
facts = await page.evaluate(inspect).catch((err) => ({ error: err.message }));
|
|
203
|
+
const buffer = await page.screenshot({ type: 'jpeg', quality: 70, fullPage: false });
|
|
204
|
+
await fs.writeFile(file, buffer);
|
|
205
|
+
toReview.push({ label: `${pagePath} at ${size.width}px (${size.name})`, dataUrl: `data:image/jpeg;base64,${buffer.toString('base64')}` });
|
|
206
|
+
}
|
|
207
|
+
await context.close();
|
|
208
|
+
|
|
209
|
+
const lines = [`### ${pagePath} at ${size.width}px (${size.name})`];
|
|
210
|
+
if (loadError) {
|
|
211
|
+
lines.push(`Could not load: ${loadError}`);
|
|
212
|
+
problems++;
|
|
213
|
+
} else {
|
|
214
|
+
lines.push(`Screenshot: ${path.relative(getRoot(), file).split(path.sep).join('/')}`);
|
|
215
|
+
if (facts?.empty) { lines.push('- The page rendered no visible text at all.'); problems++; }
|
|
216
|
+
if (facts?.overflow) {
|
|
217
|
+
lines.push(`- Content is ${facts.overflow}px wider than the screen, so it scrolls sideways:`, ...facts.wide.map((w) => ` - ${w}`));
|
|
218
|
+
problems++;
|
|
219
|
+
}
|
|
220
|
+
if (facts?.broken?.length) { lines.push(`- Broken images: ${facts.broken.join(', ')}`); problems++; }
|
|
221
|
+
if (facts?.unnamed?.length) { lines.push(`- Buttons or links with no accessible name: ${facts.unnamed.join(', ')}`); problems++; }
|
|
222
|
+
if (facts?.inputsNoLabel) { lines.push(`- ${facts.inputsNoLabel} form field(s) without a label.`); problems++; }
|
|
223
|
+
if (facts?.noAlt) lines.push(`- ${facts.noAlt} image(s) without alt text.`);
|
|
224
|
+
if (facts?.tiny) lines.push(`- ${facts.tiny} tap target(s) smaller than 32px on a phone.`);
|
|
225
|
+
if (facts?.smallText) lines.push(`- ${facts.smallText} text element(s) under 12px.`);
|
|
226
|
+
}
|
|
227
|
+
if (errors.length) { lines.push('- Console errors:', ...[...new Set(errors)].slice(0, 6).map((e) => ` - ${e}`)); problems++; }
|
|
228
|
+
if (failed.length) { lines.push('- Failed requests:', ...[...new Set(failed)].slice(0, 6).map((f) => ` - ${f}`)); problems++; }
|
|
229
|
+
if (lines.length === 2 && !loadError) lines.push('- No errors, no overflow, nothing unlabeled.');
|
|
230
|
+
sections.push(lines.join('\n'));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let critique = '';
|
|
235
|
+
if (wantReview !== false && toReview.length) {
|
|
236
|
+
try {
|
|
237
|
+
critique = await review(toReview.slice(0, 4));
|
|
238
|
+
} catch (err) {
|
|
239
|
+
critique = `(The visual review could not run: ${err.failed ?? err.message}. The checks above still apply.)`;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const body = [
|
|
244
|
+
...sections,
|
|
245
|
+
critique ? `## Visual review\n${critique}` : '',
|
|
246
|
+
'',
|
|
247
|
+
problems
|
|
248
|
+
? 'Fix the problems above, then look again to confirm.'
|
|
249
|
+
: 'The automatic checks found nothing. Weigh the visual review, fix what is worth fixing.',
|
|
250
|
+
].filter(Boolean).join('\n\n');
|
|
251
|
+
|
|
252
|
+
return result(
|
|
253
|
+
body,
|
|
254
|
+
problems
|
|
255
|
+
? `${problems} problem${problems === 1 ? '' : 's'} found · screenshots in .ucode/screenshots`
|
|
256
|
+
: 'no errors · screenshots in .ucode/screenshots'
|
|
257
|
+
);
|
|
258
|
+
}
|
package/src/tools/index.js
CHANGED
|
@@ -8,6 +8,8 @@ import { readFile, readFiles, writeFile, batchWrite, editFile, multiEdit, editFi
|
|
|
8
8
|
import { listDir, glob, grep } from './search.js';
|
|
9
9
|
import { runCommand, runCommands } from './shell.js';
|
|
10
10
|
import { webSearch } from './web.js';
|
|
11
|
+
import { lookAtApp } from './browser.js';
|
|
12
|
+
import { createApp } from './scaffold.js';
|
|
11
13
|
import { clip, READ_LINES } from './shared.js';
|
|
12
14
|
|
|
13
15
|
export { setRoot, setConfirm, getRoot } from './shared.js';
|
|
@@ -17,6 +19,25 @@ const int = (description) => ({ type: 'integer', description });
|
|
|
17
19
|
const bool = (description) => ({ type: 'boolean', description });
|
|
18
20
|
|
|
19
21
|
export const tools = [
|
|
22
|
+
{
|
|
23
|
+
name: 'create_app',
|
|
24
|
+
description:
|
|
25
|
+
'Start a new Next.js + shadcn/ui app from the ready-made ucode starter. This is how every ' +
|
|
26
|
+
'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 ' +
|
|
29
|
+
'starts installing its packages in the background so you can write components at once. ' +
|
|
30
|
+
'The result lists everything included.',
|
|
31
|
+
parameters: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: {
|
|
34
|
+
folder: str('A new, empty folder for the app, relative to the project root, e.g. "stride".'),
|
|
35
|
+
name: str('The display name of the app, e.g. "Stride".'),
|
|
36
|
+
description: str('One line about the app, used in the page metadata.'),
|
|
37
|
+
},
|
|
38
|
+
required: ['folder', 'name'],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
20
41
|
{
|
|
21
42
|
name: 'read_file',
|
|
22
43
|
description:
|
|
@@ -267,6 +288,29 @@ export const tools = [
|
|
|
267
288
|
required: ['commands'],
|
|
268
289
|
},
|
|
269
290
|
},
|
|
291
|
+
{
|
|
292
|
+
name: 'look_at_app',
|
|
293
|
+
description:
|
|
294
|
+
'Open the running app in a real browser at a phone width (375px) and a desktop width ' +
|
|
295
|
+
'(1440px) and report what a person would run into: console errors, failed requests, ' +
|
|
296
|
+
'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.',
|
|
300
|
+
parameters: {
|
|
301
|
+
type: 'object',
|
|
302
|
+
properties: {
|
|
303
|
+
url: str('The local URL the dev server reported, e.g. http://localhost:3000'),
|
|
304
|
+
paths: {
|
|
305
|
+
type: 'array',
|
|
306
|
+
description: 'Pages to open, e.g. ["/", "/settings"]. Defaults to ["/"]. Up to 4.',
|
|
307
|
+
items: { type: 'string' },
|
|
308
|
+
},
|
|
309
|
+
review: bool('Include the visual design review of the screenshots. Defaults to true.'),
|
|
310
|
+
},
|
|
311
|
+
required: ['url'],
|
|
312
|
+
},
|
|
313
|
+
},
|
|
270
314
|
{
|
|
271
315
|
name: 'web_search',
|
|
272
316
|
description:
|
|
@@ -299,6 +343,8 @@ const run = {
|
|
|
299
343
|
run_command: runCommand,
|
|
300
344
|
run_commands: runCommands,
|
|
301
345
|
web_search: webSearch,
|
|
346
|
+
look_at_app: lookAtApp,
|
|
347
|
+
create_app: createApp,
|
|
302
348
|
};
|
|
303
349
|
|
|
304
350
|
/** Tools that change the project or execute code. */
|
|
@@ -312,7 +358,7 @@ export const PARALLEL_SAFE = new Set(['read_file', 'read_files', 'list_dir', 'gl
|
|
|
312
358
|
/** Tools withheld in plan mode. Withholding beats asking a model not to. */
|
|
313
359
|
export const WRITES = new Set([
|
|
314
360
|
'write_file', 'batch_write', 'edit_file', 'multi_edit', 'edit_files', 'run_command', 'run_commands',
|
|
315
|
-
'delegate',
|
|
361
|
+
'delegate', 'create_app',
|
|
316
362
|
]);
|
|
317
363
|
|
|
318
364
|
/** Tools that change files on disk, which parallel workers take turns at. */
|
|
@@ -435,6 +481,10 @@ export function describe(name, args = {}) {
|
|
|
435
481
|
return `Running ${clip(args.command, 70)}${args.background ? ' in the background' : ''}`;
|
|
436
482
|
case 'run_commands':
|
|
437
483
|
return `Running ${args.commands?.length ?? 0} commands together`;
|
|
484
|
+
case 'create_app':
|
|
485
|
+
return `Creating ${clip(args.name || args.folder, 30)} from the Next.js starter`;
|
|
486
|
+
case 'look_at_app':
|
|
487
|
+
return `Looking at ${clip(args.url, 40)} on a phone and a desktop`;
|
|
438
488
|
case 'web_search':
|
|
439
489
|
return `Searching the web for ${clip(args.query, 60)}`;
|
|
440
490
|
case 'load_skill':
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scaffold.js — starting an app from a starter that is known to work.
|
|
3
|
+
*
|
|
4
|
+
* Setting up a Next.js + shadcn project from nothing is four minutes of
|
|
5
|
+
* create-next-app and shadcn CLI runs — measured at 116s and 130s — plus a
|
|
6
|
+
* dozen model round trips to drive them and then theme the result. Every app
|
|
7
|
+
* starts from the same place anyway, so ucode ships that place: a project
|
|
8
|
+
* that has already been built and type-checked, copied in one step, with its
|
|
9
|
+
* install starting in the background while the model writes the first
|
|
10
|
+
* component.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { promises as fs } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { ToolFailure } from '../core/failure.js';
|
|
17
|
+
import { resolveIn, guard, result } from './shared.js';
|
|
18
|
+
import { packageJsonWritten } from './shell.js';
|
|
19
|
+
|
|
20
|
+
const TEMPLATES = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates');
|
|
21
|
+
|
|
22
|
+
/** npm silently drops these two names from published packages, so they ship renamed. */
|
|
23
|
+
const RENAME = { _gitignore: '.gitignore', '_package-lock.json': 'package-lock.json' };
|
|
24
|
+
|
|
25
|
+
/** Files the placeholders are filled into. Everything else is copied byte for byte. */
|
|
26
|
+
const TEXT = /\.(?:json|md|mjs|css|tsx?)$/i;
|
|
27
|
+
|
|
28
|
+
export const TEMPLATE_NAMES = ['next-shadcn'];
|
|
29
|
+
|
|
30
|
+
function slug(name) {
|
|
31
|
+
return String(name).toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'app';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Text that is safe inside a JS string and a JSON string. */
|
|
35
|
+
const plain = (s) => String(s ?? '').replace(/["'`\\<>]/g, '').replace(/\s+/g, ' ').trim();
|
|
36
|
+
|
|
37
|
+
async function copyTree(from, to, fill) {
|
|
38
|
+
await fs.mkdir(to, { recursive: true });
|
|
39
|
+
const copied = [];
|
|
40
|
+
for (const entry of await fs.readdir(from, { withFileTypes: true })) {
|
|
41
|
+
const name = RENAME[entry.name] ?? entry.name;
|
|
42
|
+
const src = path.join(from, entry.name);
|
|
43
|
+
const dest = path.join(to, name);
|
|
44
|
+
if (entry.isDirectory()) {
|
|
45
|
+
copied.push(...(await copyTree(src, dest, fill)).map((f) => `${name}/${f}`));
|
|
46
|
+
} else if (TEXT.test(entry.name) || entry.name in RENAME) {
|
|
47
|
+
let text = await fs.readFile(src, 'utf8');
|
|
48
|
+
for (const [token, value] of Object.entries(fill)) text = text.split(token).join(value);
|
|
49
|
+
await fs.writeFile(dest, text, 'utf8');
|
|
50
|
+
copied.push(name);
|
|
51
|
+
} else {
|
|
52
|
+
await fs.copyFile(src, dest);
|
|
53
|
+
copied.push(name);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return copied;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {object} o
|
|
61
|
+
* @param {string} o.folder new, empty folder for the app
|
|
62
|
+
* @param {string} o.name display name, e.g. "Stride"
|
|
63
|
+
* @param {string} [o.description]
|
|
64
|
+
* @param {string} [o.template]
|
|
65
|
+
* @param {boolean} [o.install] start the background install (tests turn it off)
|
|
66
|
+
*/
|
|
67
|
+
export async function createApp({ folder, name, description, template = 'next-shadcn', install = true }) {
|
|
68
|
+
if (!TEMPLATE_NAMES.includes(template)) {
|
|
69
|
+
throw new ToolFailure({
|
|
70
|
+
kind: 'bad_args',
|
|
71
|
+
attempted: 'creating an app',
|
|
72
|
+
failed: `There is no starter called "${template}".`,
|
|
73
|
+
fix: `Use one of: ${TEMPLATE_NAMES.join(', ')}.`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const target = resolveIn(folder, 'create_app', 'folder');
|
|
78
|
+
const attempted = `creating an app in ${target.show}`;
|
|
79
|
+
if (target.show === '.') {
|
|
80
|
+
throw new ToolFailure({
|
|
81
|
+
kind: 'bad_args',
|
|
82
|
+
attempted,
|
|
83
|
+
failed: 'The app needs its own folder, not the project root.',
|
|
84
|
+
fix: 'Pass a new folder name, e.g. "stride".',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
await guard(target, `create an app in ${target.abs}`);
|
|
88
|
+
|
|
89
|
+
let existing = [];
|
|
90
|
+
try {
|
|
91
|
+
existing = await fs.readdir(target.abs);
|
|
92
|
+
} catch {
|
|
93
|
+
existing = [];
|
|
94
|
+
}
|
|
95
|
+
if (existing.length) {
|
|
96
|
+
throw new ToolFailure({
|
|
97
|
+
kind: 'not_empty',
|
|
98
|
+
attempted,
|
|
99
|
+
failed: `${target.show} already has ${existing.length} item(s) in it: ${existing.slice(0, 5).join(', ')}.`,
|
|
100
|
+
fix: 'Pick a new folder name. If this folder is the app from an earlier attempt, work in it instead of creating it again.',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const display = plain(name) || path.basename(target.abs);
|
|
105
|
+
const fill = {
|
|
106
|
+
__APP_NAME__: display,
|
|
107
|
+
__APP_SLUG__: slug(display),
|
|
108
|
+
__APP_DESCRIPTION__: plain(description) || display,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const files = await copyTree(path.join(TEMPLATES, template), target.abs, fill);
|
|
112
|
+
await fs.mkdir(path.join(target.abs, 'public'), { recursive: true });
|
|
113
|
+
|
|
114
|
+
if (install) {
|
|
115
|
+
const pkg = path.join(target.abs, 'package.json');
|
|
116
|
+
packageJsonWritten(pkg, await fs.readFile(pkg, 'utf8'));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const guide = await fs.readFile(path.join(target.abs, 'TEMPLATE.md'), 'utf8').catch(() => '');
|
|
120
|
+
|
|
121
|
+
return result(
|
|
122
|
+
`Created ${target.show} from the ${template} starter — ${files.length} files, already known to build.\n` +
|
|
123
|
+
(install
|
|
124
|
+
? `Its packages are installing in the background right now. Keep writing: any command you run in ` +
|
|
125
|
+
`${target.show} waits for that install first, so there is no need to run npm install.\n`
|
|
126
|
+
: '') +
|
|
127
|
+
`Run this app's commands with cwd: "${target.show}" (npm run build, npm run dev).\n\n${guide}`,
|
|
128
|
+
`${files.length} files${install ? ' · installing in the background' : ''}`
|
|
129
|
+
);
|
|
130
|
+
}
|
package/src/tools/shell.js
CHANGED
|
@@ -200,7 +200,14 @@ function startServer(command, workdir, { env } = {}) {
|
|
|
200
200
|
cwd: workdir.abs,
|
|
201
201
|
shell: true,
|
|
202
202
|
windowsHide: true,
|
|
203
|
-
detached
|
|
203
|
+
// Not detached on Windows, and this is load-bearing. A detached process
|
|
204
|
+
// there has no console, and programs launched under it write nothing
|
|
205
|
+
// to a redirected file — measured: every one of node, npm and next
|
|
206
|
+
// produced an empty log, so a server's "ready" line never arrived and
|
|
207
|
+
// every start waited out the full timer. Attached, the output lands.
|
|
208
|
+
// The server itself still outlives ucode: only this shell is tied to
|
|
209
|
+
// ucode's job object, and the job lets grandchildren break away.
|
|
210
|
+
detached: process.platform !== 'win32',
|
|
204
211
|
stdio: ['ignore', fd, fd],
|
|
205
212
|
env,
|
|
206
213
|
});
|
package/src/ui/screen.js
CHANGED
|
@@ -39,7 +39,7 @@ import chalk from 'chalk';
|
|
|
39
39
|
import {
|
|
40
40
|
theme, blue, sky, deep, dim, edge, ADDED, REMOVED, BANNER, BANNER_WIDTH, SPINNER,
|
|
41
41
|
boxTop, boxBottom, boxRow, visLen, padVis, clip, wrapAnsi,
|
|
42
|
-
shortenPath, asLabel, ensureColour, planLine,
|
|
42
|
+
shortenPath, asLabel, ensureColour, planLine, bare,
|
|
43
43
|
} from './theme.js';
|
|
44
44
|
import { renderer, render, polish } from './markdown.js';
|
|
45
45
|
import { VERSION } from '../core/version.js';
|
|
@@ -773,11 +773,15 @@ export class Screen {
|
|
|
773
773
|
* cannot be given up permanently, because under alternate scroll the mouse
|
|
774
774
|
* wheel arrives as arrow keys.
|
|
775
775
|
*/
|
|
776
|
-
pick(items, { active = 0, hint = 'enter to choose · esc to cancel' } = {}) {
|
|
776
|
+
pick(items, { active = 0, hint = 'enter to choose · esc to cancel', deletable = false } = {}) {
|
|
777
777
|
this.picker = {
|
|
778
778
|
items,
|
|
779
779
|
index: Math.min(Math.max(0, active), Math.max(0, items.length - 1)),
|
|
780
780
|
hint,
|
|
781
|
+
// With deletable, `d` twice on a row resolves { delete: index }. Twice,
|
|
782
|
+
// because a single stray keypress should never cost a conversation.
|
|
783
|
+
deletable,
|
|
784
|
+
armed: null,
|
|
781
785
|
};
|
|
782
786
|
this.render();
|
|
783
787
|
return new Promise((resolve) => { this.pickerResolve = resolve; });
|
|
@@ -799,7 +803,7 @@ export class Screen {
|
|
|
799
803
|
* about instead of a column of near-identical titles.
|
|
800
804
|
*/
|
|
801
805
|
pickerLines(height) {
|
|
802
|
-
const { items, index, hint } = this.picker;
|
|
806
|
+
const { items, index, hint, armed } = this.picker;
|
|
803
807
|
const room = Math.max(1, height - 2);
|
|
804
808
|
|
|
805
809
|
// Rows per item, so the window can be sized in rows rather than in items.
|
|
@@ -821,12 +825,15 @@ export class Screen {
|
|
|
821
825
|
for (let i = first; i <= last; i++) {
|
|
822
826
|
const item = items[i];
|
|
823
827
|
const body = typeof item === 'string' ? item : item.label;
|
|
824
|
-
|
|
828
|
+
if (i === armed) out.push(`${theme.warn('✗')} ${theme.warn(bare(body))}`);
|
|
829
|
+
else out.push(i === index ? `${blue('❯')} ${chalk.bold.white(body)}` : ` ${dim(body)}`);
|
|
825
830
|
if (typeof item !== 'string' && item.sub) out.push(` ${item.sub}`);
|
|
826
831
|
}
|
|
827
832
|
|
|
828
833
|
out.push('');
|
|
829
|
-
out.push(
|
|
834
|
+
out.push(armed !== null && armed !== undefined
|
|
835
|
+
? theme.warn(' press d again to delete this conversation · any other key keeps it')
|
|
836
|
+
: dim(` ${hint}`));
|
|
830
837
|
return out;
|
|
831
838
|
}
|
|
832
839
|
|
|
@@ -932,6 +939,13 @@ export class Screen {
|
|
|
932
939
|
// An open picker owns the keyboard until it closes.
|
|
933
940
|
if (this.picker) {
|
|
934
941
|
const last = this.picker.items.length - 1;
|
|
942
|
+
if (this.picker.deletable && (key === 'd' || key === 'D' || key === `${ESC}[3~`)) {
|
|
943
|
+
if (this.picker.armed === this.picker.index) { this.closePicker({ delete: this.picker.index }); return; }
|
|
944
|
+
this.picker.armed = this.picker.index;
|
|
945
|
+
this.render();
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
this.picker.armed = null; // any other key takes the delete back
|
|
935
949
|
if (key === `${ESC}[A`) { this.picker.index = Math.max(0, this.picker.index - 1); this.render(); return; }
|
|
936
950
|
if (key === `${ESC}[B`) { this.picker.index = Math.min(last, this.picker.index + 1); this.render(); return; }
|
|
937
951
|
if (key === '\r' || key === '\n') { this.closePicker(this.picker.index); return; }
|
|
@@ -1149,7 +1163,7 @@ export class Screen {
|
|
|
1149
1163
|
|
|
1150
1164
|
// The version, in the corner, and nothing else on the screen.
|
|
1151
1165
|
if (VERSION) {
|
|
1152
|
-
const tag = dim(`v${VERSION}`);
|
|
1166
|
+
const tag = dim(this.facts.update ? `v${VERSION} · v${this.facts.update} installed, starts next time` : `v${VERSION}`);
|
|
1153
1167
|
frame[this.rows - 1] = ' '.repeat(Math.max(0, g.cols - visLen(tag) - 2)) + tag;
|
|
1154
1168
|
}
|
|
1155
1169
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- BEGIN:nextjs-agent-rules -->
|
|
2
|
+
|
|
3
|
+
# This is NOT the Next.js you know
|
|
4
|
+
|
|
5
|
+
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
|
6
|
+
|
|
7
|
+
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
|
8
|
+
|
|
9
|
+
<!-- END:nextjs-agent-rules -->
|