ucode-agent 1.6.0 → 1.8.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 +399 -379
- package/package.json +4 -1
- package/src/core/livelog.js +113 -0
- package/src/core/login.js +59 -0
- package/src/core/loop.js +2105 -1948
- package/src/core/tests.js +86 -0
- package/src/tools/blocks.js +117 -0
- package/src/tools/cache.js +105 -0
- package/src/tools/index.js +634 -529
- package/src/tools/rename.js +157 -0
- package/src/tools/scaffold.js +24 -5
- package/src/tools/shell.js +799 -789
- package/src/tools/symbols.js +218 -0
- package/src/tools/types.js +179 -0
- package/src/ui/screen.js +1252 -1253
- package/src/ui/theme.js +49 -1
- package/templates/blocks/app-shell.tsx +81 -0
- package/templates/blocks/data-table.tsx +117 -0
- package/templates/blocks/empty-state.tsx +41 -0
- package/templates/blocks/page-header.tsx +27 -0
- package/templates/blocks/stat-cards.tsx +46 -0
- package/ucode.js +132 -124
|
@@ -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
|
+
}
|
package/src/tools/scaffold.js
CHANGED
|
@@ -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
|
|
|
@@ -170,9 +171,24 @@ export async function createApp({ folder, name, description, template = 'next-sh
|
|
|
170
171
|
await fs.mkdir(path.join(target.abs, 'public'), { recursive: true });
|
|
171
172
|
const look = await applyDesign(target.abs, design);
|
|
172
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;
|
|
173
178
|
if (install) {
|
|
174
|
-
|
|
175
|
-
|
|
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
|
+
}
|
|
176
192
|
}
|
|
177
193
|
|
|
178
194
|
const guide = await fs.readFile(path.join(target.abs, 'TEMPLATE.md'), 'utf8').catch(() => '');
|
|
@@ -180,11 +196,14 @@ export async function createApp({ folder, name, description, template = 'next-sh
|
|
|
180
196
|
return result(
|
|
181
197
|
`Created ${target.show} from the ${template} starter — ${files.length} files, already known to build.\n` +
|
|
182
198
|
(look ? `Design: the ${look.name} preset (${look.summary}), font ${look.fonts?.sans ?? 'Geist'}.\n` : '') +
|
|
183
|
-
(
|
|
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
|
|
184
203
|
? `Its packages are installing in the background right now. Keep writing: any command you run in ` +
|
|
185
204
|
`${target.show} waits for that install first, so there is no need to run npm install.\n`
|
|
186
205
|
: '') +
|
|
187
206
|
`Run this app's commands with cwd: "${target.show}" (npm run build, npm run dev).\n\n${guide}`,
|
|
188
|
-
`${files.length} files${install ? ' · installing in the background' : ''}`
|
|
207
|
+
`${files.length} files${linked ? ' · packages ready' : install ? ' · installing in the background' : ''}`
|
|
189
208
|
);
|
|
190
209
|
}
|