ucode-agent 1.26.2 → 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.
@@ -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
- * Skills are read from two places, the project first so a repo can override a
21
- * built-in of the same name:
22
- * <cwd>/.ucode/skills/<name>/SKILL.md
23
- * <install dir>/skills/<name>/SKILL.md
24
- */
25
-
26
- import { promises as fs } from 'node:fs';
27
- import path, { dirname } from 'node:path';
28
- import { fileURLToPath } from 'node:url';
29
-
30
- const HERE = dirname(fileURLToPath(import.meta.url));
31
-
32
- export const BUILTIN_DIR = path.join(HERE, '..', '..', 'skills');
33
-
34
- export function projectDir(cwd = process.cwd()) {
35
- return path.join(cwd, '.ucode', 'skills');
36
- }
37
-
38
- /** Enough YAML for `key: value`, quoted or bare. Skills are not config files. */
39
- export function parseSkill(text, source) {
40
- const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text.replace(/^/, ''));
41
- if (!match) {
42
- return { error: `${source} has no frontmatter — it must start with a --- line.` };
43
- }
44
-
45
- const meta = {};
46
- for (const line of match[1].split(/\r?\n/)) {
47
- const kv = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line.trim());
48
- if (!kv) continue;
49
- let value = kv[2].trim();
50
- if ((value.startsWith('"') && value.endsWith('"')) ||
51
- (value.startsWith("'") && value.endsWith("'"))) {
52
- value = value.slice(1, -1);
53
- }
54
- meta[kv[1]] = value;
55
- }
56
-
57
- if (!meta.name) return { error: `${source} frontmatter has no "name".` };
58
- if (!meta.description) return { error: `${source} frontmatter has no "description".` };
59
-
60
- return {
61
- skill: {
62
- name: meta.name,
63
- description: meta.description,
64
- // Words that pull this skill in before the model has said anything.
65
- triggers: (meta.auto ?? '')
66
- .split(',')
67
- .map((t) => t.trim().toLowerCase())
68
- .filter(Boolean),
69
- body: match[2].trim(),
70
- source,
71
- },
72
- };
73
- }
74
-
75
- async function readDir(dir) {
76
- const skills = [];
77
- const problems = [];
78
-
79
- let entries;
80
- try {
81
- entries = await fs.readdir(dir, { withFileTypes: true });
82
- } catch {
83
- return { skills, problems }; // no skills directory is a normal state
84
- }
85
-
86
- for (const entry of entries) {
87
- if (!entry.isDirectory()) continue;
88
- const file = path.join(dir, entry.name, 'SKILL.md');
89
- let text;
90
- try {
91
- text = await fs.readFile(file, 'utf8');
92
- } catch (err) {
93
- if (err.code !== 'ENOENT') problems.push(`${file} could not be read: ${err.message}`);
94
- continue;
95
- }
96
- const { skill, error } = parseSkill(text, file);
97
- if (error) problems.push(error);
98
- else skills.push(skill);
99
- }
100
-
101
- return { skills, problems };
102
- }
103
-
104
- /**
105
- * Every available skill, project ones shadowing built-ins by name.
106
- * `problems` rides along non-enumerably for the UI to report.
107
- */
108
- export async function loadSkills({ cwd = process.cwd() } = {}) {
109
- const builtin = await readDir(BUILTIN_DIR);
110
- const project = await readDir(projectDir(cwd));
111
-
112
- const byName = new Map();
113
- for (const s of builtin.skills) byName.set(s.name, s);
114
- for (const s of project.skills) byName.set(s.name, s);
115
-
116
- const skills = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
117
- Object.defineProperty(skills, 'problems', {
118
- value: [...builtin.problems, ...project.problems],
119
- enumerable: false,
120
- });
121
- return skills;
122
- }
123
-
124
- /** The system-prompt block: names and descriptions, never bodies. */
125
- export function catalogue(skills) {
126
- if (!skills.length) return '';
127
- return skills.map((s) => `- ${s.name}: ${s.description}`).join('\n');
128
- }
129
-
130
- export function findSkill(skills, name) {
131
- const wanted = String(name ?? '').trim().toLowerCase();
132
- return skills.find((s) => s.name.toLowerCase() === wanted);
133
- }
134
-
135
- /**
136
- * Which skills this request should arrive with already loaded.
137
- *
138
- * A trigger matches on a word boundary, so "app" fires on "build me an app"
139
- * but not on "happy". Multi-word triggers are matched as phrases.
140
- */
141
- export function autoLoadFor(skills, text) {
142
- const request = String(text ?? '').toLowerCase();
143
- if (!request.trim()) return [];
144
-
145
- return skills.filter((skill) =>
146
- skill.triggers.some((trigger) => {
147
- const escaped = trigger.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
148
- return new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`, 'i').test(request);
149
- })
150
- );
151
- }
152
-
153
- /** How a body enters the conversation. */
154
- export function skillMessage(skill, { automatic = false } = {}) {
155
- const why = automatic
156
- ? `The "${skill.name}" skill was loaded automatically because this request is the kind it covers.`
157
- : `The "${skill.name}" skill was loaded for this task.`;
158
- return {
159
- role: 'system',
160
- content:
161
- `${why} Follow it — it outranks your defaults, and it is not optional.\n\n` +
162
- `--- BEGIN SKILL: ${skill.name} ---\n${skill.body}\n--- END SKILL ---`,
163
- skill: skill.name,
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
+ }
@@ -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 * FOLD_AT;
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
- const cut = cutPoint(messages, limit * KEEP);
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
 
@@ -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
- * These are the parts every app needs, written once and carefully: a shell, a
10
- * page header, an empty state, a table, a row of stats. They are copied into
11
- * the app as ordinary source files for the model to edit, not imported from a
12
- * library it cannot change.
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 listing = () =>
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
- * Copy a block into an app, or list what there is. The file lands in
59
- * src/components/blocks/ and is the app's to edit from then on.
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 you can add, with add_block({ name, folder }):\n\n${listing()}\n\n` +
67
- 'Each one is copied into the app as a source file you can then edit.',
68
- `${BLOCK_NAMES.length} blocks`
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 (!CATALOGUE[wanted]) {
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: `There is no block called "${wanted}".`,
77
- fix: `Pick one of: ${BLOCK_NAMES.join(', ')}. Call add_block with no name to see what each is for.`,
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 source = await fs.readFile(path.join(BLOCKS, `${wanted}.tsx`), 'utf8').catch(() => null);
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 the component by hand, or reinstall ucode.',
176
+ fix: 'Write it by hand, or reinstall ucode.',
91
177
  });
92
178
  }
93
179
 
94
- const rel = path.join('src', 'components', 'blocks', `${wanted}.tsx`);
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 fs.stat(dest).catch(() => null)) {
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 = CATALOGUE[wanted].exports.split(',')[0].trim();
198
+ const first = catalogue[wanted].exports.split(',')[0].trim();
109
199
  return result(
110
- `Added ${rel} to ${target.show}.\n\n` +
111
- `import { ${CATALOGUE[wanted].exports} } from "@/components/blocks/${wanted}";\n\n` +
112
- `${CATALOGUE[wanted].use}\n\n` +
113
- `It is an ordinary file now — edit it to suit the app rather than working around it. ` +
114
- `It uses the shadcn components already in the starter, so nothing needs installing.`,
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
  }