ucode-agent 1.26.1 → 1.27.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 +36 -7
- package/package.json +1 -1
- package/skills/build-app/DIGEST.md +94 -0
- package/skills/build-app/SKILL.md +222 -181
- package/skills/ui-ux/DIGEST.md +135 -0
- package/src/core/context.js +164 -151
- package/src/core/loop.js +209 -39
- package/src/core/provider.js +874 -868
- package/src/core/skills.js +189 -165
- package/src/core/window.js +27 -2
- package/src/tools/blocks.js +117 -27
- package/src/tools/index.js +71 -23
- package/src/tools/scaffold.js +104 -14
- package/src/ui/plain.js +4 -2
- package/src/ui/screen.js +14 -5
- package/src/ui/theme.js +100 -24
- package/templates/blocks/plain/filter-bar.js +133 -0
- package/templates/blocks/plain/item-list.js +249 -0
- package/templates/blocks/plain/modal.js +141 -0
- package/templates/blocks/plain/store.js +93 -0
- package/templates/blocks/plain/theme-toggle.js +116 -0
- package/templates/blocks/plain/toast.js +107 -0
- package/templates/plain-html/styles.css +4 -0
package/src/core/skills.js
CHANGED
|
@@ -1,165 +1,189 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* skills.js — instruction packs, disclosed progressively.
|
|
3
|
-
*
|
|
4
|
-
* A skill is a folder holding a SKILL.md: YAML frontmatter, then the
|
|
5
|
-
* instructions. Only names and one-line descriptions go into the system
|
|
6
|
-
* prompt; a body is pulled into the conversation the moment it is wanted. The
|
|
7
|
-
* base prompt therefore stays the same size whether there are three skills or
|
|
8
|
-
* thirty.
|
|
9
|
-
*
|
|
10
|
-
* Two ways a body gets loaded:
|
|
11
|
-
*
|
|
12
|
-
* the model asks for it, with the load_skill tool; or
|
|
13
|
-
* the frontmatter says `auto:` and one of those words is in the request,
|
|
14
|
-
* in which case it is already loaded before the model takes its first step.
|
|
15
|
-
*
|
|
16
|
-
* The second one exists because the first one is a judgement call, and a model
|
|
17
|
-
* in a hurry to be helpful skips judgement calls. Design quality is not
|
|
18
|
-
* something to find out was skipped after the app is built.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
1
|
+
/**
|
|
2
|
+
* skills.js — instruction packs, disclosed progressively.
|
|
3
|
+
*
|
|
4
|
+
* A skill is a folder holding a SKILL.md: YAML frontmatter, then the
|
|
5
|
+
* instructions. Only names and one-line descriptions go into the system
|
|
6
|
+
* prompt; a body is pulled into the conversation the moment it is wanted. The
|
|
7
|
+
* base prompt therefore stays the same size whether there are three skills or
|
|
8
|
+
* thirty.
|
|
9
|
+
*
|
|
10
|
+
* Two ways a body gets loaded:
|
|
11
|
+
*
|
|
12
|
+
* the model asks for it, with the load_skill tool; or
|
|
13
|
+
* the frontmatter says `auto:` and one of those words is in the request,
|
|
14
|
+
* in which case it is already loaded before the model takes its first step.
|
|
15
|
+
*
|
|
16
|
+
* The second one exists because the first one is a judgement call, and a model
|
|
17
|
+
* in a hurry to be helpful skips judgement calls. Design quality is not
|
|
18
|
+
* something to find out was skipped after the app is built.
|
|
19
|
+
*
|
|
20
|
+
* A skill folder may also hold a DIGEST.md: the same rules, cut to the ones
|
|
21
|
+
* that are never worth skipping. That is what an automatic load sends, and it
|
|
22
|
+
* is a latency decision rather than a token one — the whole conversation is
|
|
23
|
+
* re-read by the provider on every single step, so four thousand tokens
|
|
24
|
+
* loaded on the word "app" is four thousand tokens re-read ten or twenty
|
|
25
|
+
* times before the app is finished. The full body stays one load_skill away
|
|
26
|
+
* for work that needs the depth.
|
|
27
|
+
*
|
|
28
|
+
* Skills are read from two places, the project first so a repo can override a
|
|
29
|
+
* built-in of the same name:
|
|
30
|
+
* <cwd>/.ucode/skills/<name>/SKILL.md
|
|
31
|
+
* <install dir>/skills/<name>/SKILL.md
|
|
32
|
+
* and beside either of them, an optional DIGEST.md.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { promises as fs } from 'node:fs';
|
|
36
|
+
import path, { dirname } from 'node:path';
|
|
37
|
+
import { fileURLToPath } from 'node:url';
|
|
38
|
+
|
|
39
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
40
|
+
|
|
41
|
+
export const BUILTIN_DIR = path.join(HERE, '..', '..', 'skills');
|
|
42
|
+
|
|
43
|
+
export function projectDir(cwd = process.cwd()) {
|
|
44
|
+
return path.join(cwd, '.ucode', 'skills');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Enough YAML for `key: value`, quoted or bare. Skills are not config files. */
|
|
48
|
+
export function parseSkill(text, source) {
|
|
49
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text.replace(/^/, ''));
|
|
50
|
+
if (!match) {
|
|
51
|
+
return { error: `${source} has no frontmatter — it must start with a --- line.` };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const meta = {};
|
|
55
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
56
|
+
const kv = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line.trim());
|
|
57
|
+
if (!kv) continue;
|
|
58
|
+
let value = kv[2].trim();
|
|
59
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
60
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
61
|
+
value = value.slice(1, -1);
|
|
62
|
+
}
|
|
63
|
+
meta[kv[1]] = value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!meta.name) return { error: `${source} frontmatter has no "name".` };
|
|
67
|
+
if (!meta.description) return { error: `${source} frontmatter has no "description".` };
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
skill: {
|
|
71
|
+
name: meta.name,
|
|
72
|
+
description: meta.description,
|
|
73
|
+
// Words that pull this skill in before the model has said anything.
|
|
74
|
+
triggers: (meta.auto ?? '')
|
|
75
|
+
.split(',')
|
|
76
|
+
.map((t) => t.trim().toLowerCase())
|
|
77
|
+
.filter(Boolean),
|
|
78
|
+
body: match[2].trim(),
|
|
79
|
+
source,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function readDir(dir) {
|
|
85
|
+
const skills = [];
|
|
86
|
+
const problems = [];
|
|
87
|
+
|
|
88
|
+
let entries;
|
|
89
|
+
try {
|
|
90
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
91
|
+
} catch {
|
|
92
|
+
return { skills, problems }; // no skills directory is a normal state
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const entry of entries) {
|
|
96
|
+
if (!entry.isDirectory()) continue;
|
|
97
|
+
const file = path.join(dir, entry.name, 'SKILL.md');
|
|
98
|
+
let text;
|
|
99
|
+
try {
|
|
100
|
+
text = await fs.readFile(file, 'utf8');
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (err.code !== 'ENOENT') problems.push(`${file} could not be read: ${err.message}`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const { skill, error } = parseSkill(text, file);
|
|
106
|
+
if (error) { problems.push(error); continue; }
|
|
107
|
+
// The short form, if the skill has one. No frontmatter: it is the same
|
|
108
|
+
// skill, said in fewer words.
|
|
109
|
+
skill.digest = (await fs.readFile(path.join(dir, entry.name, 'DIGEST.md'), 'utf8').catch(() => '')).trim();
|
|
110
|
+
skills.push(skill);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { skills, problems };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Every available skill, project ones shadowing built-ins by name.
|
|
118
|
+
* `problems` rides along non-enumerably for the UI to report.
|
|
119
|
+
*/
|
|
120
|
+
export async function loadSkills({ cwd = process.cwd() } = {}) {
|
|
121
|
+
const builtin = await readDir(BUILTIN_DIR);
|
|
122
|
+
const project = await readDir(projectDir(cwd));
|
|
123
|
+
|
|
124
|
+
const byName = new Map();
|
|
125
|
+
for (const s of builtin.skills) byName.set(s.name, s);
|
|
126
|
+
for (const s of project.skills) byName.set(s.name, s);
|
|
127
|
+
|
|
128
|
+
const skills = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
129
|
+
Object.defineProperty(skills, 'problems', {
|
|
130
|
+
value: [...builtin.problems, ...project.problems],
|
|
131
|
+
enumerable: false,
|
|
132
|
+
});
|
|
133
|
+
return skills;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The system-prompt block: names and descriptions, never bodies. */
|
|
137
|
+
export function catalogue(skills) {
|
|
138
|
+
if (!skills.length) return '';
|
|
139
|
+
return skills.map((s) => `- ${s.name}: ${s.description}`).join('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function findSkill(skills, name) {
|
|
143
|
+
const wanted = String(name ?? '').trim().toLowerCase();
|
|
144
|
+
return skills.find((s) => s.name.toLowerCase() === wanted);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Which skills this request should arrive with already loaded.
|
|
149
|
+
*
|
|
150
|
+
* A trigger matches on a word boundary, so "app" fires on "build me an app"
|
|
151
|
+
* but not on "happy". Multi-word triggers are matched as phrases.
|
|
152
|
+
*/
|
|
153
|
+
export function autoLoadFor(skills, text) {
|
|
154
|
+
const request = String(text ?? '').toLowerCase();
|
|
155
|
+
if (!request.trim()) return [];
|
|
156
|
+
|
|
157
|
+
return skills.filter((skill) =>
|
|
158
|
+
skill.triggers.some((trigger) => {
|
|
159
|
+
const escaped = trigger.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
160
|
+
return new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`, 'i').test(request);
|
|
161
|
+
})
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* How a body enters the conversation.
|
|
167
|
+
*
|
|
168
|
+
* `short` sends the digest instead of the whole skill, when the skill has
|
|
169
|
+
* one. Everything in the digest is a rule; what is missing is the worked
|
|
170
|
+
* examples and the long way round, and load_skill fetches those.
|
|
171
|
+
*/
|
|
172
|
+
export function skillMessage(skill, { automatic = false, short = false } = {}) {
|
|
173
|
+
const digest = short && skill.digest ? skill.digest : null;
|
|
174
|
+
const why = automatic
|
|
175
|
+
? `The "${skill.name}" skill was loaded automatically because this request is the kind it covers.`
|
|
176
|
+
: `The "${skill.name}" skill was loaded for this task.`;
|
|
177
|
+
return {
|
|
178
|
+
role: 'system',
|
|
179
|
+
content:
|
|
180
|
+
`${why} Follow it — it outranks your defaults, and it is not optional.\n\n` +
|
|
181
|
+
`--- BEGIN SKILL: ${skill.name} ---\n${digest ?? skill.body}\n--- END SKILL ---` +
|
|
182
|
+
(digest
|
|
183
|
+
? `\n\nThat is the short form: every rule, none of the worked examples. For anything ` +
|
|
184
|
+
`beyond a straightforward screen, call load_skill("${skill.name}") for the whole thing.`
|
|
185
|
+
: ''),
|
|
186
|
+
skill: skill.name,
|
|
187
|
+
short: Boolean(digest),
|
|
188
|
+
};
|
|
189
|
+
}
|
package/src/core/window.js
CHANGED
|
@@ -18,6 +18,22 @@ import { estimateConversation } from './provider.js';
|
|
|
18
18
|
/** Start folding once the conversation passes this share of the window. */
|
|
19
19
|
export const FOLD_AT = 0.75;
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* ...or once it passes this many tokens, whichever comes first.
|
|
23
|
+
*
|
|
24
|
+
* A share of the window is a correctness threshold: it stops the request
|
|
25
|
+
* being rejected. It is the wrong measure for speed. These endpoints re-read
|
|
26
|
+
* the whole conversation on every step and none of them cache it, so a
|
|
27
|
+
* hundred thousand tokens is a hundred thousand tokens re-read ten times
|
|
28
|
+
* before the app is finished — and on a million-token model, three quarters
|
|
29
|
+
* of the window is a build that has been crawling for an hour by the time
|
|
30
|
+
* anything is folded.
|
|
31
|
+
*
|
|
32
|
+
* Folding costs one model call. Past this size, that call has already paid
|
|
33
|
+
* for itself in the steps that follow it.
|
|
34
|
+
*/
|
|
35
|
+
export const FOLD_TOKENS = Number(process.env.UCODE_FOLD_TOKENS) || 80_000;
|
|
36
|
+
|
|
21
37
|
/** After folding, the verbatim tail may occupy this share of the window. */
|
|
22
38
|
export const KEEP = 0.4;
|
|
23
39
|
|
|
@@ -32,7 +48,12 @@ export function usage(messages, limit) {
|
|
|
32
48
|
}
|
|
33
49
|
|
|
34
50
|
export function tooBig(messages, limit) {
|
|
35
|
-
return usage(messages, limit).used > limit
|
|
51
|
+
return usage(messages, limit).used > foldAbove(limit);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The size at which folding starts: whichever of the two rules bites first. */
|
|
55
|
+
export function foldAbove(limit) {
|
|
56
|
+
return Math.min(limit * FOLD_AT, FOLD_TOKENS);
|
|
36
57
|
}
|
|
37
58
|
|
|
38
59
|
/**
|
|
@@ -71,7 +92,11 @@ function cutPoint(messages, budget) {
|
|
|
71
92
|
export async function fold(messages, { limit, summarize }) {
|
|
72
93
|
if (!tooBig(messages, limit)) return { messages, folded: false };
|
|
73
94
|
|
|
74
|
-
|
|
95
|
+
// Half the size that triggered the fold, so there is room to work before
|
|
96
|
+
// the next one. Sized off the same absolute rule, or a fold on a
|
|
97
|
+
// million-token model would keep a tail that is instantly too big again and
|
|
98
|
+
// summarize on every single step.
|
|
99
|
+
const cut = cutPoint(messages, Math.min(limit * KEEP, foldAbove(limit) / 2));
|
|
75
100
|
const older = messages.slice(0, cut);
|
|
76
101
|
const recent = messages.slice(cut);
|
|
77
102
|
|
package/src/tools/blocks.js
CHANGED
|
@@ -6,10 +6,14 @@
|
|
|
6
6
|
* that it cannot do better, it is that doing better costs steps and attention
|
|
7
7
|
* that belong to the thing being built.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* It also costs time in the plainest sense. Typing is the slowest part of a
|
|
10
|
+
* build — a few thousand tokens at forty a second — so a list, a filter row
|
|
11
|
+
* and a store that arrive written are a minute the user does not wait.
|
|
12
|
+
*
|
|
13
|
+
* Two sets, because the two starters have nothing in common: React and shadcn
|
|
14
|
+
* components for next-shadcn, and plain ES modules with no imports at all for
|
|
15
|
+
* plain-html. Which one you get is decided by the app the block is going into,
|
|
16
|
+
* not by an argument the model has to remember.
|
|
13
17
|
*/
|
|
14
18
|
|
|
15
19
|
import { promises as fs } from 'node:fs';
|
|
@@ -49,55 +53,141 @@ export const CATALOGUE = {
|
|
|
49
53
|
},
|
|
50
54
|
};
|
|
51
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The same idea for a page with no build step: plain ES modules that import
|
|
58
|
+
* nothing, style themselves from the starter's CSS variables, and are ordinary
|
|
59
|
+
* files the moment they land.
|
|
60
|
+
*/
|
|
61
|
+
export const PLAIN_CATALOGUE = {
|
|
62
|
+
'item-list': {
|
|
63
|
+
what: 'A list of things you can add, tick off, rename and remove — a to-do list, a shopping list, saved items. Keyboard throughout, empty state, a count.',
|
|
64
|
+
exports: 'ItemList',
|
|
65
|
+
use: `const list = ItemList({
|
|
66
|
+
items: store.state.items,
|
|
67
|
+
onChange: (items) => store.set({ items }),
|
|
68
|
+
placeholder: 'Add a task',
|
|
69
|
+
});
|
|
70
|
+
document.querySelector('#app').append(list.el);`,
|
|
71
|
+
},
|
|
72
|
+
'filter-bar': {
|
|
73
|
+
what: 'A row of filters — All / Active / Done — as real buttons in a labelled group, one tab stop, arrow keys between them.',
|
|
74
|
+
exports: 'FilterBar',
|
|
75
|
+
use: `const filters = FilterBar({
|
|
76
|
+
options: [{ value: 'all', label: 'All' }, { value: 'active', label: 'Active' }],
|
|
77
|
+
value: 'all',
|
|
78
|
+
onChange: (value) => { state.filter = value; render(); },
|
|
79
|
+
});`,
|
|
80
|
+
},
|
|
81
|
+
store: {
|
|
82
|
+
what: 'State in one place, saved to localStorage, and the same in every open tab. Survives private mode, a full quota and a value written by an older version.',
|
|
83
|
+
exports: 'createStore',
|
|
84
|
+
use: `const store = createStore('tide', { items: [], filter: 'all' });
|
|
85
|
+
store.subscribe(render);
|
|
86
|
+
store.update((s) => ({ items: [...s.items, task] }));`,
|
|
87
|
+
},
|
|
88
|
+
modal: {
|
|
89
|
+
what: 'A dialog that behaves like one: Escape closes it, focus is trapped inside and returns to the opener. Built on the browser\'s own <dialog>.',
|
|
90
|
+
exports: 'Modal',
|
|
91
|
+
use: `const confirm = Modal({ title: 'Delete this?', danger: true, onConfirm: remove });
|
|
92
|
+
document.body.append(confirm.el);
|
|
93
|
+
button.addEventListener('click', () => confirm.open());`,
|
|
94
|
+
},
|
|
95
|
+
toast: {
|
|
96
|
+
what: 'A short message that appears, is read out by a screen reader, and goes away. Stacks, caps itself, pauses under the pointer.',
|
|
97
|
+
exports: 'toast',
|
|
98
|
+
use: `toast('Saved');
|
|
99
|
+
toast.error('Could not reach the server');
|
|
100
|
+
toast('Deleted', { action: { label: 'Undo', onClick: undo } });`,
|
|
101
|
+
},
|
|
102
|
+
'theme-toggle': {
|
|
103
|
+
what: 'Light and dark: follows the operating system until the user chooses, remembers the choice, and applies it before the first paint so nothing flashes.',
|
|
104
|
+
exports: 'ThemeToggle, applyTheme, currentTheme',
|
|
105
|
+
use: `applyTheme(); // the first line of app.js
|
|
106
|
+
header.append(ThemeToggle().el);`,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
52
110
|
export const BLOCK_NAMES = Object.keys(CATALOGUE);
|
|
111
|
+
export const PLAIN_BLOCK_NAMES = Object.keys(PLAIN_CATALOGUE);
|
|
112
|
+
export const ALL_BLOCK_NAMES = [...new Set([...PLAIN_BLOCK_NAMES, ...BLOCK_NAMES])];
|
|
53
113
|
|
|
54
|
-
const
|
|
55
|
-
BLOCK_NAMES.map((name) => ` ${name} — ${CATALOGUE[name].what}`).join('\n');
|
|
114
|
+
const exists = (p) => fs.stat(p).then(() => true, () => false);
|
|
56
115
|
|
|
57
116
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
117
|
+
* Which set this app takes.
|
|
118
|
+
*
|
|
119
|
+
* A page with an index.html and nothing to install is the plain starter; a
|
|
120
|
+
* folder with a package.json is React. Asked rather than argued about, so
|
|
121
|
+
* the model cannot pick the wrong one.
|
|
122
|
+
*/
|
|
123
|
+
async function kindOf(dir) {
|
|
124
|
+
if (await exists(path.join(dir, 'package.json'))) return 'react';
|
|
125
|
+
if (await exists(path.join(dir, 'index.html'))) return 'plain';
|
|
126
|
+
return 'react';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const listing = (catalogue) =>
|
|
130
|
+
Object.keys(catalogue).map((name) => ` ${name} — ${catalogue[name].what}`).join('\n');
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Copy a block into an app, or list what there is. React blocks land in
|
|
134
|
+
* src/components/blocks/, plain ones in blocks/, and either way the file is
|
|
135
|
+
* the app's to edit from then on.
|
|
60
136
|
*/
|
|
61
137
|
export async function addBlock({ name, folder = '.' }) {
|
|
62
138
|
const wanted = String(name ?? '').trim();
|
|
139
|
+
const target = resolveIn(folder || '.', 'add_block', 'folder');
|
|
140
|
+
const kind = await kindOf(target.abs);
|
|
141
|
+
const catalogue = kind === 'plain' ? PLAIN_CATALOGUE : CATALOGUE;
|
|
142
|
+
const names = Object.keys(catalogue);
|
|
63
143
|
|
|
64
144
|
if (!wanted) {
|
|
65
145
|
return result(
|
|
66
|
-
`Blocks
|
|
67
|
-
|
|
68
|
-
|
|
146
|
+
`Blocks for ${target.show} (${kind === 'plain' ? 'a page with no build step' : 'React and shadcn'}), ` +
|
|
147
|
+
`with add_block({ name, folder }):\n\n${listing(catalogue)}\n\n` +
|
|
148
|
+
'Each one is copied in as a source file you can then edit.',
|
|
149
|
+
`${names.length} blocks`
|
|
69
150
|
);
|
|
70
151
|
}
|
|
71
152
|
|
|
72
|
-
if (!
|
|
153
|
+
if (!catalogue[wanted]) {
|
|
154
|
+
const other = kind === 'plain' ? CATALOGUE : PLAIN_CATALOGUE;
|
|
73
155
|
throw new ToolFailure({
|
|
74
156
|
kind: 'no_such_block',
|
|
75
157
|
attempted: `adding the "${wanted}" block`,
|
|
76
|
-
failed:
|
|
77
|
-
|
|
158
|
+
failed: other[wanted]
|
|
159
|
+
? `"${wanted}" is a ${kind === 'plain' ? 'React' : 'plain-page'} block, and ${target.show} is ${kind === 'plain' ? 'a plain page' : 'a React app'}.`
|
|
160
|
+
: `There is no block called "${wanted}".`,
|
|
161
|
+
fix: `For ${target.show}, pick one of: ${names.join(', ')}. Call add_block with no name to see what each is for.`,
|
|
78
162
|
});
|
|
79
163
|
}
|
|
80
164
|
|
|
81
|
-
const target = resolveIn(folder || '.', 'add_block', 'folder');
|
|
82
165
|
await guard(target, `add the ${wanted} block to ${target.abs}`);
|
|
83
166
|
|
|
84
|
-
const
|
|
167
|
+
const from = kind === 'plain'
|
|
168
|
+
? path.join(BLOCKS, 'plain', `${wanted}.js`)
|
|
169
|
+
: path.join(BLOCKS, `${wanted}.tsx`);
|
|
170
|
+
const source = await fs.readFile(from, 'utf8').catch(() => null);
|
|
85
171
|
if (source === null) {
|
|
86
172
|
throw new ToolFailure({
|
|
87
173
|
kind: 'block_missing',
|
|
88
174
|
attempted: `adding the "${wanted}" block`,
|
|
89
175
|
failed: `The ${wanted} block is listed but its file is not installed.`,
|
|
90
|
-
fix: 'Write
|
|
176
|
+
fix: 'Write it by hand, or reinstall ucode.',
|
|
91
177
|
});
|
|
92
178
|
}
|
|
93
179
|
|
|
94
|
-
const rel =
|
|
180
|
+
const rel = kind === 'plain'
|
|
181
|
+
? path.join('blocks', `${wanted}.js`)
|
|
182
|
+
: path.join('src', 'components', 'blocks', `${wanted}.tsx`);
|
|
95
183
|
const dest = path.join(target.abs, rel);
|
|
184
|
+
const importLine = kind === 'plain'
|
|
185
|
+
? `import { ${catalogue[wanted].exports} } from './blocks/${wanted}.js';`
|
|
186
|
+
: `import { ${catalogue[wanted].exports} } from "@/components/blocks/${wanted}";`;
|
|
96
187
|
|
|
97
|
-
if (await
|
|
188
|
+
if (await exists(dest)) {
|
|
98
189
|
return result(
|
|
99
|
-
`${rel} is already in ${target.show}; it has been left as it is so your edits survive.\n
|
|
100
|
-
`Import: import { ${CATALOGUE[wanted].exports.split(',')[0].trim()} } from "@/components/blocks/${wanted}";`,
|
|
190
|
+
`${rel} is already in ${target.show}; it has been left as it is so your edits survive.\n${importLine}`,
|
|
101
191
|
'already there'
|
|
102
192
|
);
|
|
103
193
|
}
|
|
@@ -105,13 +195,13 @@ export async function addBlock({ name, folder = '.' }) {
|
|
|
105
195
|
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
106
196
|
await fs.writeFile(dest, source, 'utf8');
|
|
107
197
|
|
|
108
|
-
const first =
|
|
198
|
+
const first = catalogue[wanted].exports.split(',')[0].trim();
|
|
109
199
|
return result(
|
|
110
|
-
`Added ${rel} to ${target.show}.\n\n` +
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
200
|
+
`Added ${rel} to ${target.show}.\n\n${importLine}\n\n${catalogue[wanted].use}\n\n` +
|
|
201
|
+
'It is an ordinary file now — edit it to suit the app rather than working around it. ' +
|
|
202
|
+
(kind === 'plain'
|
|
203
|
+
? 'It imports nothing and styles itself from the CSS variables already in styles.css, so it works as it is.'
|
|
204
|
+
: 'It uses the shadcn components already in the starter, so nothing needs installing.'),
|
|
115
205
|
`${first} added`
|
|
116
206
|
);
|
|
117
207
|
}
|