ucode-agent 1.5.0 → 1.7.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 (71) hide show
  1. package/README.md +399 -327
  2. package/package.json +6 -1
  3. package/skills/ui-ux/SKILL.md +2 -2
  4. package/src/core/doctor.js +122 -0
  5. package/src/core/livelog.js +113 -0
  6. package/src/core/loop.js +2105 -1659
  7. package/src/core/provider.js +93 -10
  8. package/src/core/stuck.js +269 -0
  9. package/src/core/tests.js +86 -0
  10. package/src/tools/blocks.js +117 -0
  11. package/src/tools/browser.js +121 -59
  12. package/src/tools/cache.js +105 -0
  13. package/src/tools/deploy.js +283 -0
  14. package/src/tools/files.js +91 -8
  15. package/src/tools/index.js +634 -495
  16. package/src/tools/rename.js +157 -0
  17. package/src/tools/scaffold.js +85 -6
  18. package/src/tools/shell.js +799 -701
  19. package/src/tools/symbols.js +218 -0
  20. package/src/tools/types.js +179 -0
  21. package/src/ui/activity.js +203 -0
  22. package/src/ui/plain.js +22 -3
  23. package/src/ui/screen.js +65 -19
  24. package/src/ui/theme.js +5 -1
  25. package/templates/blocks/app-shell.tsx +81 -0
  26. package/templates/blocks/data-table.tsx +117 -0
  27. package/templates/blocks/empty-state.tsx +41 -0
  28. package/templates/blocks/page-header.tsx +27 -0
  29. package/templates/blocks/stat-cards.tsx +46 -0
  30. package/templates/next-shadcn/TEMPLATE.md +53 -9
  31. package/templates/next-shadcn/_package-lock.json +1335 -148
  32. package/templates/next-shadcn/components.json +1 -1
  33. package/templates/next-shadcn/next.config.ts +2 -1
  34. package/templates/next-shadcn/package.json +4 -2
  35. package/templates/next-shadcn/presets/citrus.json +77 -0
  36. package/templates/next-shadcn/presets/graphite.json +77 -0
  37. package/templates/next-shadcn/presets/grove.json +77 -0
  38. package/templates/next-shadcn/presets/ocean.json +78 -0
  39. package/templates/next-shadcn/presets/sunset.json +77 -0
  40. package/templates/next-shadcn/presets/violet.json +77 -0
  41. package/templates/next-shadcn/src/components/ui/accordion.tsx +80 -0
  42. package/templates/next-shadcn/src/components/ui/alert-dialog.tsx +34 -22
  43. package/templates/next-shadcn/src/components/ui/avatar.tsx +7 -4
  44. package/templates/next-shadcn/src/components/ui/badge.tsx +15 -18
  45. package/templates/next-shadcn/src/components/ui/button.tsx +12 -3
  46. package/templates/next-shadcn/src/components/ui/calendar.tsx +1 -0
  47. package/templates/next-shadcn/src/components/ui/checkbox.tsx +6 -2
  48. package/templates/next-shadcn/src/components/ui/collapsible.tsx +33 -0
  49. package/templates/next-shadcn/src/components/ui/command.tsx +1 -2
  50. package/templates/next-shadcn/src/components/ui/dialog.tsx +34 -26
  51. package/templates/next-shadcn/src/components/ui/dropdown-menu.tsx +115 -114
  52. package/templates/next-shadcn/src/components/ui/hover-card.tsx +43 -0
  53. package/templates/next-shadcn/src/components/ui/input-group.tsx +2 -4
  54. package/templates/next-shadcn/src/components/ui/input.tsx +1 -2
  55. package/templates/next-shadcn/src/components/ui/label.tsx +6 -2
  56. package/templates/next-shadcn/src/components/ui/popover.tsx +27 -28
  57. package/templates/next-shadcn/src/components/ui/progress.tsx +11 -63
  58. package/templates/next-shadcn/src/components/ui/radio-group.tsx +43 -0
  59. package/templates/next-shadcn/src/components/ui/scroll-area.tsx +6 -6
  60. package/templates/next-shadcn/src/components/ui/select.tsx +55 -64
  61. package/templates/next-shadcn/src/components/ui/separator.tsx +6 -3
  62. package/templates/next-shadcn/src/components/ui/sheet.tsx +35 -26
  63. package/templates/next-shadcn/src/components/ui/slider.tsx +58 -0
  64. package/templates/next-shadcn/src/components/ui/switch.tsx +3 -2
  65. package/templates/next-shadcn/src/components/ui/table.tsx +115 -0
  66. package/templates/next-shadcn/src/components/ui/tabs.tsx +16 -8
  67. package/templates/next-shadcn/src/components/ui/toggle-group.tsx +89 -0
  68. package/templates/next-shadcn/src/components/ui/toggle.tsx +46 -0
  69. package/templates/next-shadcn/src/components/ui/tooltip.tsx +24 -33
  70. package/templates/next-shadcn/src/lib/utils.ts +6 -1
  71. package/ucode.js +8 -1
@@ -0,0 +1,157 @@
1
+ /**
2
+ * rename.js — renaming a name everywhere it is that name.
3
+ *
4
+ * Most failed edits are a find-and-replace that matched too much: renaming
5
+ * `id` rewrites `width`, `idle` and every `id` inside a string or a comment,
6
+ * and the model then spends three steps undoing it. The fix is not a bigger
7
+ * regular expression — it is to stop treating code as text.
8
+ *
9
+ * This walks each file as code: it knows where a string, a template literal
10
+ * and a comment begin and end, and only renames an identifier sitting in
11
+ * actual code, whole, not as part of a longer word. That is short of a
12
+ * parser — it cannot tell two different `user` variables in two scopes apart
13
+ * — so it reports exactly what it changed and where, and leaves the model to
14
+ * read the diff. It is the difference between an edit that is usually right
15
+ * and one that is usually wrong.
16
+ */
17
+
18
+ import { promises as fs } from 'node:fs';
19
+ import path from 'node:path';
20
+ import { ToolFailure } from '../core/failure.js';
21
+ import { resolveIn, guard, result, walk } from './shared.js';
22
+
23
+ const SOURCE = /\.(?:[cm]?[jt]sx?|py)$/i;
24
+ const IDENT = /^[A-Za-z_$][\w$]*$/;
25
+ const wordChar = (c) => c !== undefined && /[\w$]/.test(c);
26
+
27
+ /**
28
+ * Rename `from` to `to` in one file's text, skipping strings and comments.
29
+ * Returns the new text and the 1-based lines that changed.
30
+ */
31
+ export function renameIn(text, from, to) {
32
+ const out = [];
33
+ const lines = [];
34
+ let line = 1;
35
+ let i = 0;
36
+ const n = text.length;
37
+
38
+ // Where we are: code, or inside something that is not code.
39
+ let mode = 'code';
40
+ let quote = '';
41
+ // Template literals can hold ${ code }, so the nesting is tracked.
42
+ const templates = [];
43
+
44
+ while (i < n) {
45
+ const c = text[i];
46
+ const next = text[i + 1];
47
+ if (c === '\n') line++;
48
+
49
+ if (mode === 'line-comment') {
50
+ if (c === '\n') mode = 'code';
51
+ out.push(c); i++; continue;
52
+ }
53
+ if (mode === 'block-comment') {
54
+ if (c === '*' && next === '/') { out.push('*/'); i += 2; mode = 'code'; continue; }
55
+ out.push(c); i++; continue;
56
+ }
57
+ if (mode === 'string') {
58
+ if (c === '\\') { out.push(c, next ?? ''); i += 2; continue; }
59
+ if (c === quote) { mode = 'code'; quote = ''; }
60
+ if (c === '\n' && quote !== '`') { mode = 'code'; quote = ''; } // an unterminated quote
61
+ out.push(c); i++; continue;
62
+ }
63
+ if (mode === 'template') {
64
+ if (c === '\\') { out.push(c, next ?? ''); i += 2; continue; }
65
+ if (c === '`') { mode = templates.pop() ?? 'code'; out.push(c); i++; continue; }
66
+ if (c === '$' && next === '{') { templates.push('template'); mode = 'code'; out.push('${'); i += 2; continue; }
67
+ out.push(c); i++; continue;
68
+ }
69
+
70
+ // mode === 'code'
71
+ if (c === '/' && next === '/') { mode = 'line-comment'; out.push('//'); i += 2; continue; }
72
+ if (c === '/' && next === '*') { mode = 'block-comment'; out.push('/*'); i += 2; continue; }
73
+ if (c === '#') { mode = 'line-comment'; out.push(c); i++; continue; } // python
74
+ if (c === '"' || c === "'") { mode = 'string'; quote = c; out.push(c); i++; continue; }
75
+ if (c === '`') { mode = 'template'; out.push(c); i++; continue; }
76
+ if (c === '}' && templates.length) { mode = templates.pop(); out.push(c); i++; continue; }
77
+
78
+ if (/[A-Za-z_$]/.test(c)) {
79
+ let j = i;
80
+ while (j < n && wordChar(text[j])) j++;
81
+ const word = text.slice(i, j);
82
+ if (word === from && !wordChar(text[i - 1])) {
83
+ out.push(to);
84
+ if (lines[lines.length - 1] !== line) lines.push(line);
85
+ } else {
86
+ out.push(word);
87
+ }
88
+ i = j;
89
+ continue;
90
+ }
91
+
92
+ out.push(c);
93
+ i++;
94
+ }
95
+
96
+ return { text: out.join(''), lines };
97
+ }
98
+
99
+ /** Rename a name across every source file under a path. */
100
+ export async function renameSymbol({ name, to, path: p = '.' }) {
101
+ const from = String(name ?? '').trim();
102
+ const into = String(to ?? '').trim();
103
+
104
+ if (!IDENT.test(from) || !IDENT.test(into)) {
105
+ throw new ToolFailure({
106
+ kind: 'bad_args',
107
+ attempted: `renaming ${from || '(nothing)'} to ${into || '(nothing)'}`,
108
+ failed: 'Both names must be plain identifiers: letters, digits, _ or $, not starting with a digit.',
109
+ fix: 'To change something that is not an identifier, use edit_file or multi_edit.',
110
+ });
111
+ }
112
+ if (from === into) {
113
+ throw new ToolFailure({
114
+ kind: 'bad_args',
115
+ attempted: `renaming ${from}`,
116
+ failed: 'The old and new names are the same.',
117
+ fix: 'Pass the name you want it to become.',
118
+ });
119
+ }
120
+
121
+ const target = resolveIn(p || '.', 'rename_symbol', 'path');
122
+ await guard(target, `rename ${from} to ${into} under ${target.abs}`);
123
+
124
+ const stat = await fs.stat(target.abs).catch(() => null);
125
+ const rels = stat?.isFile() ? [''] : (await walk(target.abs, {})).filter((rel) => SOURCE.test(rel));
126
+
127
+ const changed = [];
128
+ let total = 0;
129
+
130
+ for (const rel of rels) {
131
+ const abs = rel ? path.join(target.abs, rel) : target.abs;
132
+ const before = await fs.readFile(abs, 'utf8').catch(() => null);
133
+ if (before === null || !before.includes(from)) continue;
134
+ const { text, lines } = renameIn(before, from, into);
135
+ if (!lines.length || text === before) continue;
136
+ await fs.writeFile(abs, text, 'utf8');
137
+ changed.push({ rel: rel || target.show, lines });
138
+ total += lines.length;
139
+ }
140
+
141
+ if (!changed.length) {
142
+ return result(
143
+ `Nothing to rename: "${from}" does not appear as a name in any code under ${target.show}.\n` +
144
+ 'It may only exist in strings or comments, which are deliberately left alone, or be spelled differently.',
145
+ 'no occurrences'
146
+ );
147
+ }
148
+
149
+ const shown = changed.slice(0, 40).map((f) => `${f.rel} (${f.lines.length} line${f.lines.length === 1 ? '' : 's'}: ${f.lines.slice(0, 12).join(', ')}${f.lines.length > 12 ? '…' : ''})`);
150
+ const more = changed.length > shown.length ? `\n[${changed.length - shown.length} more files]` : '';
151
+
152
+ return result(
153
+ `Renamed ${from} to ${into} in ${changed.length} file${changed.length === 1 ? '' : 's'}:\n\n${shown.join('\n')}${more}\n\n` +
154
+ 'Strings and comments were left alone. If the name was meant to change in one of those too, edit it directly.',
155
+ `${total} line${total === 1 ? '' : 's'} in ${changed.length} file${changed.length === 1 ? '' : 's'}`
156
+ );
157
+ }
@@ -15,7 +15,8 @@ import path from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import { ToolFailure } from '../core/failure.js';
17
17
  import { resolveIn, guard, result } from './shared.js';
18
- import { packageJsonWritten } from './shell.js';
18
+ import { packageJsonWritten, installIn } from './shell.js';
19
+ import { restore, populate } from './cache.js';
19
20
 
20
21
  const TEMPLATES = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates');
21
22
 
@@ -64,7 +65,65 @@ async function copyTree(from, to, fill) {
64
65
  * @param {string} [o.template]
65
66
  * @param {boolean} [o.install] start the background install (tests turn it off)
66
67
  */
67
- export async function createApp({ folder, name, description, template = 'next-shadcn', install = true }) {
68
+ /**
69
+ * Give the new app its look: one of the hand-picked presets in the starter's
70
+ * presets/ folder — a full light and dark palette and a font — written into
71
+ * globals.css and layout.tsx. Apps stop looking like the same default blue.
72
+ * Returns the preset used, or null when the starter has none.
73
+ */
74
+ export async function applyDesign(appDir, design) {
75
+ const dir = path.join(appDir, 'presets');
76
+ const names = (await fs.readdir(dir).catch(() => [])).filter((f) => f.endsWith('.json'));
77
+ if (!names.length) return null;
78
+ const presets = await Promise.all(names.map(async (f) => JSON.parse(await fs.readFile(path.join(dir, f), 'utf8'))));
79
+ await fs.rm(dir, { recursive: true, force: true }); // the app needs the result, not the catalogue
80
+ const preset = presets.find((p) => p.name === design) ?? presets.find((p) => p.default) ?? presets[0];
81
+
82
+ const cssFile = path.join(appDir, 'src', 'app', 'globals.css');
83
+ let css = await fs.readFile(cssFile, 'utf8').catch(() => null);
84
+ if (css !== null) {
85
+ const retint = (selector, tokens) => {
86
+ const block = new RegExp(`(${selector}\\s*\\{)([\\s\\S]*?)(\\n\\})`);
87
+ css = css.replace(block, (all, open, body, close) => {
88
+ const seen = new Set();
89
+ let next = body.replace(/(\n\s*)--([\w-]+):\s*[^;]+;/g, (line, lead, key) => {
90
+ if (!(key in tokens)) return line;
91
+ seen.add(key);
92
+ return `${lead}--${key}: ${tokens[key]};`;
93
+ });
94
+ for (const [key, value] of Object.entries(tokens)) if (!seen.has(key)) next += `\n --${key}: ${value};`;
95
+ return open + next + close;
96
+ });
97
+ };
98
+ retint(':root', { radius: preset.radius, ...preset.light });
99
+ retint('\\.dark', preset.dark);
100
+ await fs.writeFile(cssFile, css);
101
+ }
102
+
103
+ const sans = preset.fonts?.sans;
104
+ const layoutFile = path.join(appDir, 'src', 'app', 'layout.tsx');
105
+ if (sans && sans !== 'Geist') {
106
+ const id = sans.replace(/\s+/g, '_');
107
+ const layout = await fs.readFile(layoutFile, 'utf8').catch(() => null);
108
+ if (layout !== null) {
109
+ await fs.writeFile(layoutFile, layout
110
+ .replace('import { Geist, Geist_Mono } from "next/font/google";', `import { ${id}, Geist_Mono } from "next/font/google";`)
111
+ .replace('const sans = Geist({', `const sans = ${id}({`));
112
+ }
113
+ }
114
+
115
+ const guide = path.join(appDir, 'TEMPLATE.md');
116
+ const text = await fs.readFile(guide, 'utf8').catch(() => null);
117
+ if (text !== null) {
118
+ await fs.writeFile(guide, `${text.trimEnd()}\n\n## Design\n\nThis app uses the **${preset.name}** preset — ` +
119
+ `${preset.summary}. Font: ${sans ?? 'Geist'}. The palette lives in globals.css (light and dark): ` +
120
+ 'build with the tokens (bg-primary, text-muted-foreground, border, ...) rather than raw colours, ' +
121
+ 'so every screen stays in one look.\n');
122
+ }
123
+ return preset;
124
+ }
125
+
126
+ export async function createApp({ folder, name, description, template = 'next-shadcn', design, install = true }) {
68
127
  if (!TEMPLATE_NAMES.includes(template)) {
69
128
  throw new ToolFailure({
70
129
  kind: 'bad_args',
@@ -110,21 +169,41 @@ export async function createApp({ folder, name, description, template = 'next-sh
110
169
 
111
170
  const files = await copyTree(path.join(TEMPLATES, template), target.abs, fill);
112
171
  await fs.mkdir(path.join(target.abs, 'public'), { recursive: true });
172
+ const look = await applyDesign(target.abs, design);
113
173
 
174
+ // The starter has been installed on this machine before: hard-link that
175
+ // tree in, which is seconds where npm is a minute. Otherwise install as
176
+ // usual, and keep the result so the next app is instant.
177
+ let linked = 0;
114
178
  if (install) {
115
- const pkg = path.join(target.abs, 'package.json');
116
- packageJsonWritten(pkg, await fs.readFile(pkg, 'utf8'));
179
+ // Keyed on the starter's lockfile, which is the same for every app made
180
+ // from it — the app's own is rewritten by npm as it installs.
181
+ const lockText = await fs
182
+ .readFile(path.join(TEMPLATES, template, '_package-lock.json'), 'utf8')
183
+ .catch(() => null);
184
+ linked = await restore(target.abs, template, lockText);
185
+ if (!linked) {
186
+ const pkg = path.join(target.abs, 'package.json');
187
+ packageJsonWritten(pkg, await fs.readFile(pkg, 'utf8'));
188
+ installIn(target.abs)?.then((done) => {
189
+ if (done?.code === 0) populate(target.abs, template, lockText);
190
+ });
191
+ }
117
192
  }
118
193
 
119
194
  const guide = await fs.readFile(path.join(target.abs, 'TEMPLATE.md'), 'utf8').catch(() => '');
120
195
 
121
196
  return result(
122
197
  `Created ${target.show} from the ${template} starter — ${files.length} files, already known to build.\n` +
123
- (install
198
+ (look ? `Design: the ${look.name} preset (${look.summary}), font ${look.fonts?.sans ?? 'Geist'}.\n` : '') +
199
+ (linked
200
+ ? `Its packages are already in place (${linked.toLocaleString()} files, linked from the starter cache) — ` +
201
+ 'nothing to install: build and run straight away.\n'
202
+ : install
124
203
  ? `Its packages are installing in the background right now. Keep writing: any command you run in ` +
125
204
  `${target.show} waits for that install first, so there is no need to run npm install.\n`
126
205
  : '') +
127
206
  `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' : ''}`
207
+ `${files.length} files${linked ? ' · packages ready' : install ? ' · installing in the background' : ''}`
129
208
  );
130
209
  }