total-recall-brain 3.31.2 → 3.32.1
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/bin/total-recall.mjs +20 -2
- package/package.json +2 -2
- package/scaffold/.agent/skills/plugins/SKILL.md +5 -0
- package/scaffold/.agent/skills/total-recall/SKILL.md +33 -3
- package/src/cli/api.mjs +133 -0
- package/src/cli/app/index.mjs +181 -1
- package/src/cli/brief.mjs +195 -0
- package/src/cli/command.mjs +269 -5
- package/src/cli/config.mjs +25 -7
- package/src/cli/connect.mjs +1 -1
- package/src/cli/daemon.mjs +3 -3
- package/src/cli/group.mjs +151 -0
- package/src/cli/init.mjs +54 -0
- package/src/cli/plugin/create.mjs +20 -10
- package/src/cli/project-id.mjs +43 -0
- package/src/cli/repo-expert-generate.mjs +37 -3
- package/src/cli/secret.mjs +36 -3
- package/src/cli/skill-config.mjs +211 -0
- package/src/cli/skill.mjs +37 -2
- package/src/core/api-routes.mjs +99 -0
- package/src/core/app-deploy/adapter-contract.mjs +176 -0
- package/src/core/app-deploy/design-tokens.mjs +148 -108
- package/src/core/app-deploy/plan.mjs +2 -2
- package/src/core/app-deploy/resolve.mjs +30 -2
- package/src/core/app-deploy/runtime-cli/node.mjs.tmpl +211 -0
- package/src/core/app-deploy/runtime-cli/python.py.tmpl +178 -0
- package/src/core/app-deploy/runtime-cli.mjs +254 -0
- package/src/core/app-deploy/ui-elements.mjs +473 -0
- package/src/core/app-deploy/verify.mjs +18 -0
- package/src/core/command-surface.mjs +129 -0
- package/src/core/daemon-control.mjs +31 -12
- package/src/core/daemon-loop.mjs +14 -7
- package/src/core/plugin-loader.mjs +12 -12
- package/src/core/project-id.mjs +72 -0
- package/src/core/provider-catalog.mjs +1 -1
- package/src/core/schema.mjs +72 -13
- package/src/core/secrets-env-export.mjs +3 -4
- package/src/core/secrets-store.mjs +30 -29
- package/src/core/skill-config.mjs +376 -0
- package/src/core/skills-registry.mjs +147 -31
- package/src/core/source-adapters.mjs +2 -2
- package/src/core/surface.mjs +109 -16
- package/src/server/routes/help.mjs +1 -1
- package/src/server/routes/system.mjs +1 -1
- package/plugins/code-quality/README.md +0 -25
- package/plugins/code-quality/cli.mjs +0 -25
- package/plugins/code-quality/plugin.json +0 -17
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Total Recall — plugin UI elements and their framework adapters.
|
|
3
|
+
*
|
|
4
|
+
* A plugin authors each UI element once, as a self-contained custom element
|
|
5
|
+
* (an ES module that defines exactly one tag, imports nothing, and styles itself
|
|
6
|
+
* only with the design-token CSS variables it declares). This module validates
|
|
7
|
+
* the manifest's `ui` spec and the element sources, then generates, without
|
|
8
|
+
* writing, the files a host app needs:
|
|
9
|
+
*
|
|
10
|
+
* web-components tokens.css + elements/<id>.js + index.js (Flask/Jinja, any HTML host)
|
|
11
|
+
* react the same, plus react/<Name>.jsx|tsx wrappers for React/Next
|
|
12
|
+
*
|
|
13
|
+
* The app's DESIGN.md becomes tokens.css, so an app restyles every element by
|
|
14
|
+
* editing its tokens; the element files do not change. Generation is
|
|
15
|
+
* deterministic: the same plugin, tokens, and options give byte-identical files.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { parseDesignTokens, validatePluginUiAgainstTokens } from './design-tokens.mjs';
|
|
22
|
+
|
|
23
|
+
export const UI_TARGETS = ['web-components', 'react'];
|
|
24
|
+
const ADAPTER_UI_TARGETS = { 'ssss-app': 'web-components', flask: 'web-components', nextjs: 'react', react: 'react' };
|
|
25
|
+
export const UI_ELEMENT_KINDS = ['panel', 'form', 'list', 'detail', 'settings', 'widget'];
|
|
26
|
+
const PROP_TYPES = ['string', 'number', 'boolean', 'object', 'array'];
|
|
27
|
+
const MAX_ELEMENT_BYTES = 256 * 1024;
|
|
28
|
+
export const UI_MANIFEST_FILE = 'ui-manifest.json';
|
|
29
|
+
|
|
30
|
+
const ELEMENT_ID = /^[a-z][a-z0-9-]{0,63}$/;
|
|
31
|
+
// Custom element names: lowercase, start with a letter, contain a hyphen.
|
|
32
|
+
const TAG = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/;
|
|
33
|
+
const RESERVED_TAGS = new Set(['annotation-xml', 'color-profile', 'font-face', 'font-face-src', 'font-face-uri', 'font-face-format', 'font-face-name', 'missing-glyph']);
|
|
34
|
+
const PROP_NAME = /^[a-z][a-zA-Z0-9]{0,63}$/;
|
|
35
|
+
const RESERVED_PROPS = new Set(['children', 'key', 'ref', 'className', 'style', 'class', 'id', 'slot', 'hidden', 'title', 'lang', 'dir']);
|
|
36
|
+
const EVENT_NAME = /^[a-z][a-z0-9-]{0,63}$/;
|
|
37
|
+
const TOKEN_NAME = /^[a-z][a-z0-9-]{0,127}$/;
|
|
38
|
+
|
|
39
|
+
export class UiSpecError extends Error {
|
|
40
|
+
constructor(errors) {
|
|
41
|
+
super(`Invalid ui spec:\n- ${errors.join('\n- ')}`);
|
|
42
|
+
this.name = 'UiSpecError';
|
|
43
|
+
this.errors = errors;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class UiElementError extends Error {
|
|
48
|
+
constructor(errors) {
|
|
49
|
+
super(`UI elements failed validation:\n- ${errors.join('\n- ')}`);
|
|
50
|
+
this.name = 'UiElementError';
|
|
51
|
+
this.errors = errors;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
56
|
+
const sha256 = (text) => crypto.createHash('sha256').update(text).digest('hex');
|
|
57
|
+
const pascal = (id) => id.split('-').map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('');
|
|
58
|
+
export const eventHandlerName = (event) => `on${pascal(event)}`;
|
|
59
|
+
|
|
60
|
+
function isSafePluginPath(p, extension) {
|
|
61
|
+
if (typeof p !== 'string' || !p || p.includes('\0') || p.includes('\\')) return false;
|
|
62
|
+
if (path.posix.isAbsolute(p)) return false;
|
|
63
|
+
const normalized = path.posix.normalize(p);
|
|
64
|
+
return normalized === p && !normalized.startsWith('../') && normalized !== '..' && extension.test(p);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function unknownKeys(obj, allowed, where, errors) {
|
|
68
|
+
for (const key of Object.keys(obj)) {
|
|
69
|
+
if (!allowed.includes(key)) errors.push(`${where}: unknown key '${key}'`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function tokenList(value, where, errors) {
|
|
74
|
+
if (value === undefined) return [];
|
|
75
|
+
if (!Array.isArray(value)) {
|
|
76
|
+
errors.push(`${where} must be an array of token names`);
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const token of value) {
|
|
81
|
+
if (typeof token !== 'string' || !TOKEN_NAME.test(token)) errors.push(`${where}: '${token}' is not a token name (kebab-case, without the leading --)`);
|
|
82
|
+
else if (out.includes(token)) errors.push(`${where}: '${token}' is listed twice`);
|
|
83
|
+
else out.push(token);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizeElement(raw, index, errors) {
|
|
89
|
+
const where = `ui.elements[${index}]`;
|
|
90
|
+
if (!isPlainObject(raw)) {
|
|
91
|
+
errors.push(`${where} must be an object`);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
unknownKeys(raw, ['id', 'kind', 'tag', 'module', 'slot', 'description', 'props', 'events', 'tokens', 'optional_tokens'], where, errors);
|
|
95
|
+
const el = { id: raw.id, kind: raw.kind, tag: raw.tag, module: raw.module };
|
|
96
|
+
if (typeof raw.id !== 'string' || !ELEMENT_ID.test(raw.id)) errors.push(`${where}.id must be lowercase kebab-case`);
|
|
97
|
+
if (!UI_ELEMENT_KINDS.includes(raw.kind)) errors.push(`${where}.kind must be one of: ${UI_ELEMENT_KINDS.join(', ')}`);
|
|
98
|
+
if (typeof raw.tag !== 'string' || !TAG.test(raw.tag) || RESERVED_TAGS.has(raw.tag)) {
|
|
99
|
+
errors.push(`${where}.tag must be a valid custom element name (lowercase, with a hyphen)`);
|
|
100
|
+
}
|
|
101
|
+
if (!isSafePluginPath(raw.module, /\.m?js$/)) errors.push(`${where}.module must be a normalized relative .js/.mjs path inside the plugin`);
|
|
102
|
+
if (raw.slot !== undefined) {
|
|
103
|
+
if (typeof raw.slot !== 'string' || !ELEMENT_ID.test(raw.slot)) errors.push(`${where}.slot must be lowercase kebab-case`);
|
|
104
|
+
else el.slot = raw.slot;
|
|
105
|
+
}
|
|
106
|
+
if (raw.description !== undefined) {
|
|
107
|
+
if (typeof raw.description !== 'string') errors.push(`${where}.description must be a string`);
|
|
108
|
+
else el.description = raw.description;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
el.props = {};
|
|
112
|
+
if (raw.props !== undefined) {
|
|
113
|
+
if (!isPlainObject(raw.props)) errors.push(`${where}.props must be an object of name → { type }`);
|
|
114
|
+
else {
|
|
115
|
+
for (const [name, def] of Object.entries(raw.props)) {
|
|
116
|
+
const at = `${where}.props.${name}`;
|
|
117
|
+
if (!PROP_NAME.test(name) || RESERVED_PROPS.has(name) || /^on[A-Z]/.test(name)) {
|
|
118
|
+
errors.push(`${at}: prop names are camelCase, not reserved, and do not start with 'on'`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (!isPlainObject(def)) {
|
|
122
|
+
errors.push(`${at} must be an object`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
unknownKeys(def, ['type', 'description', 'required'], at, errors);
|
|
126
|
+
if (!PROP_TYPES.includes(def.type)) errors.push(`${at}.type must be one of: ${PROP_TYPES.join(', ')}`);
|
|
127
|
+
if (def.required !== undefined && typeof def.required !== 'boolean') errors.push(`${at}.required must be a boolean`);
|
|
128
|
+
if (def.description !== undefined && typeof def.description !== 'string') errors.push(`${at}.description must be a string`);
|
|
129
|
+
el.props[name] = { type: def.type, ...(def.description ? { description: def.description } : {}), required: def.required === true };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
el.events = [];
|
|
135
|
+
if (raw.events !== undefined) {
|
|
136
|
+
if (!Array.isArray(raw.events)) errors.push(`${where}.events must be an array of event names`);
|
|
137
|
+
else {
|
|
138
|
+
for (const event of raw.events) {
|
|
139
|
+
if (typeof event !== 'string' || !EVENT_NAME.test(event)) errors.push(`${where}.events: '${event}' must be lowercase kebab-case`);
|
|
140
|
+
else if (el.events.includes(event)) errors.push(`${where}.events: '${event}' is listed twice`);
|
|
141
|
+
else el.events.push(event);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
el.tokens = tokenList(raw.tokens, `${where}.tokens`, errors);
|
|
147
|
+
el.optional_tokens = tokenList(raw.optional_tokens, `${where}.optional_tokens`, errors);
|
|
148
|
+
for (const token of el.optional_tokens) {
|
|
149
|
+
if (el.tokens.includes(token)) errors.push(`${where}: '${token}' is both required and optional`);
|
|
150
|
+
}
|
|
151
|
+
return el;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Validates and normalizes a manifest `ui` block.
|
|
156
|
+
* @param {object} ui
|
|
157
|
+
* @returns {{ design_tokens?: string, elements: object[] }}
|
|
158
|
+
* @throws {UiSpecError}
|
|
159
|
+
*/
|
|
160
|
+
export function normalizeUiSpec(ui) {
|
|
161
|
+
const errors = [];
|
|
162
|
+
if (!isPlainObject(ui)) throw new UiSpecError(["'ui' must be an object"]);
|
|
163
|
+
unknownKeys(ui, ['design_tokens', 'elements'], 'ui', errors);
|
|
164
|
+
const spec = { elements: [] };
|
|
165
|
+
if (ui.design_tokens !== undefined) {
|
|
166
|
+
if (!isSafePluginPath(ui.design_tokens, /\.md$/)) {
|
|
167
|
+
errors.push('ui.design_tokens must be a normalized relative path to a DESIGN.md-format .md file inside the plugin');
|
|
168
|
+
} else spec.design_tokens = ui.design_tokens;
|
|
169
|
+
}
|
|
170
|
+
if (ui.elements !== undefined && !Array.isArray(ui.elements)) errors.push("'ui.elements' must be an array");
|
|
171
|
+
const ids = new Set();
|
|
172
|
+
const tags = new Set();
|
|
173
|
+
(Array.isArray(ui.elements) ? ui.elements : []).forEach((raw, i) => {
|
|
174
|
+
const el = normalizeElement(raw, i, errors);
|
|
175
|
+
if (!el) return;
|
|
176
|
+
if (ids.has(el.id)) errors.push(`ui.elements[${i}]: duplicate id '${el.id}'`);
|
|
177
|
+
if (tags.has(el.tag)) errors.push(`ui.elements[${i}]: duplicate tag '${el.tag}'`);
|
|
178
|
+
ids.add(el.id);
|
|
179
|
+
tags.add(el.tag);
|
|
180
|
+
spec.elements.push(el);
|
|
181
|
+
});
|
|
182
|
+
if (errors.length) throw new UiSpecError(errors);
|
|
183
|
+
return spec;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** @returns {string[]} validation errors; empty when valid */
|
|
187
|
+
export function validateUiSpec(ui) {
|
|
188
|
+
try {
|
|
189
|
+
normalizeUiSpec(ui);
|
|
190
|
+
return [];
|
|
191
|
+
} catch (err) {
|
|
192
|
+
if (err instanceof UiSpecError) return err.errors;
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function uiTargetForAdapter(adapter) {
|
|
198
|
+
const target = ADAPTER_UI_TARGETS[adapter];
|
|
199
|
+
if (!target) throw new Error(`Adapter '${adapter}' has no UI element target (known: ${Object.keys(ADAPTER_UI_TARGETS).join(', ')})`);
|
|
200
|
+
return target;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function stripComments(source) {
|
|
204
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`\\])\/\/[^\n]*/g, '$1');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Checks one element module against its declaration.
|
|
209
|
+
* @returns {{ errors: string[], warnings: string[] }}
|
|
210
|
+
*/
|
|
211
|
+
export function validateUiElementSource(source, element) {
|
|
212
|
+
const errors = [];
|
|
213
|
+
const warnings = [];
|
|
214
|
+
const at = `element '${element.id}' (${element.module})`;
|
|
215
|
+
if (Buffer.byteLength(source) > MAX_ELEMENT_BYTES) errors.push(`${at}: larger than ${MAX_ELEMENT_BYTES} bytes`);
|
|
216
|
+
const code = stripComments(source);
|
|
217
|
+
if (/(^|[;\n{}])\s*(import|export)\b[^\n(]*\bfrom\s*['"]/.test(code) || /(^|[;\n])\s*import\s*['"]/.test(code) || /\bimport\s*\(/.test(code) || /\brequire\s*\(/.test(code)) {
|
|
218
|
+
errors.push(`${at}: element modules must be self-contained (no import, export-from, or require)`);
|
|
219
|
+
}
|
|
220
|
+
const defines = [...code.matchAll(/customElements\.define\(\s*(['"`])([^'"`]+)\1/g)].map((m) => m[2]);
|
|
221
|
+
if (defines.length !== 1 || defines[0] !== element.tag) {
|
|
222
|
+
errors.push(`${at}: must call customElements.define('${element.tag}', …) exactly once, for its own tag (found: ${defines.join(', ') || 'none'})`);
|
|
223
|
+
}
|
|
224
|
+
const allowed = [...element.tokens, ...element.optional_tokens];
|
|
225
|
+
const check = validatePluginUiAgainstTokens(source, { allowedTokens: allowed });
|
|
226
|
+
for (const v of check.violations) errors.push(`${at}: ${v}`);
|
|
227
|
+
const unused = allowed.filter((t) => !check.usedTokens.includes(t));
|
|
228
|
+
if (unused.length) warnings.push(`${at}: declares tokens it never uses: ${unused.join(', ')}`);
|
|
229
|
+
return { errors, warnings };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function readPluginFile(pluginDir, rel, where) {
|
|
233
|
+
const root = fs.realpathSync(pluginDir);
|
|
234
|
+
const full = path.resolve(root, rel);
|
|
235
|
+
let real;
|
|
236
|
+
try {
|
|
237
|
+
real = fs.realpathSync(full);
|
|
238
|
+
} catch {
|
|
239
|
+
throw new UiElementError([`${where}: ${rel} does not exist in the plugin`]);
|
|
240
|
+
}
|
|
241
|
+
if (real !== root && !real.startsWith(root + path.sep)) throw new UiElementError([`${where}: ${rel} resolves outside the plugin`]);
|
|
242
|
+
if (!fs.statSync(real).isFile()) throw new UiElementError([`${where}: ${rel} is not a file`]);
|
|
243
|
+
return fs.readFileSync(real, 'utf8');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const TS_TYPES = { string: 'string', number: 'number', boolean: 'boolean', object: 'Record<string, unknown>', array: 'unknown[]' };
|
|
247
|
+
|
|
248
|
+
function reactWrapper(element, { typescript, header }) {
|
|
249
|
+
const name = pascal(element.id);
|
|
250
|
+
const props = Object.keys(element.props);
|
|
251
|
+
const events = element.events.map((event) => [eventHandlerName(event), event]);
|
|
252
|
+
const destructured = [...props, ...events.map(([handler]) => handler)];
|
|
253
|
+
const lines = ["'use client';", header, ''];
|
|
254
|
+
lines.push(typescript
|
|
255
|
+
? "import { createElement, useEffect, useRef, useState, type HTMLAttributes } from 'react';"
|
|
256
|
+
: "import { createElement, useEffect, useRef, useState } from 'react';");
|
|
257
|
+
lines.push('', `const TAG = ${JSON.stringify(element.tag)};`, '');
|
|
258
|
+
if (typescript) {
|
|
259
|
+
const omitted = destructured.length ? `Omit<HTMLAttributes<HTMLElement>, ${destructured.map((k) => JSON.stringify(k)).join(' | ')}>` : 'HTMLAttributes<HTMLElement>';
|
|
260
|
+
lines.push(`export interface ${name}Props extends ${omitted} {`);
|
|
261
|
+
for (const prop of props) {
|
|
262
|
+
const def = element.props[prop];
|
|
263
|
+
if (def.description) lines.push(` /** ${def.description.replace(/\*\//g, '* /')} */`);
|
|
264
|
+
lines.push(` ${prop}${def.required ? '' : '?'}: ${TS_TYPES[def.type]};`);
|
|
265
|
+
}
|
|
266
|
+
for (const [handler, event] of events) lines.push(` /** '${event}' event */`, ` ${handler}?: (event: CustomEvent) => void;`);
|
|
267
|
+
lines.push('}', '');
|
|
268
|
+
}
|
|
269
|
+
if (element.description) lines.push(`/** ${element.description.replace(/\*\//g, '* /')} */`);
|
|
270
|
+
const signature = typescript ? `props: ${name}Props` : 'props';
|
|
271
|
+
lines.push(`export default function ${name}(${signature}) {`);
|
|
272
|
+
lines.push(` const { ${[...destructured, '...rest'].join(', ')} } = props;`);
|
|
273
|
+
lines.push(typescript ? ' const ref = useRef<HTMLElement | null>(null);' : ' const ref = useRef(null);');
|
|
274
|
+
lines.push(' const [ready, setReady] = useState(false);', '');
|
|
275
|
+
lines.push(' // Load the element on the client only, then set properties once it is defined,');
|
|
276
|
+
lines.push(' // so a value set before the upgrade never shadows the element\'s own setter.');
|
|
277
|
+
lines.push(' useEffect(() => {');
|
|
278
|
+
lines.push(' let live = true;');
|
|
279
|
+
lines.push(` import('../elements/${element.id}.js')`);
|
|
280
|
+
lines.push(' .then(() => customElements.whenDefined(TAG))');
|
|
281
|
+
lines.push(' .then(() => { if (live) setReady(true); })');
|
|
282
|
+
lines.push(` .catch((err) => console.error(\`Could not load <\${TAG}>:\`, err));`);
|
|
283
|
+
lines.push(' return () => { live = false; };');
|
|
284
|
+
lines.push(' }, []);');
|
|
285
|
+
for (const prop of props) {
|
|
286
|
+
lines.push('', ' useEffect(() => {');
|
|
287
|
+
lines.push(typescript
|
|
288
|
+
? ` if (ready && ref.current) (ref.current as unknown as Record<string, unknown>).${prop} = ${prop};`
|
|
289
|
+
: ` if (ready && ref.current) ref.current.${prop} = ${prop};`);
|
|
290
|
+
lines.push(` }, [ready, ${prop}]);`);
|
|
291
|
+
}
|
|
292
|
+
for (const [handler, event] of events) {
|
|
293
|
+
lines.push('', ' useEffect(() => {');
|
|
294
|
+
lines.push(' const el = ref.current;');
|
|
295
|
+
lines.push(` if (!el || !${handler}) return undefined;`);
|
|
296
|
+
lines.push(typescript ? ` const listener = ${handler} as unknown as EventListener;` : ` const listener = ${handler};`);
|
|
297
|
+
lines.push(` el.addEventListener(${JSON.stringify(event)}, listener);`);
|
|
298
|
+
lines.push(` return () => el.removeEventListener(${JSON.stringify(event)}, listener);`);
|
|
299
|
+
lines.push(` }, [${handler}]);`);
|
|
300
|
+
}
|
|
301
|
+
lines.push('', ' return createElement(TAG, { ...rest, ref });', '}', '');
|
|
302
|
+
return lines.join('\n');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Generates a host app's UI element files. Writes nothing.
|
|
307
|
+
*
|
|
308
|
+
* @param {object} manifest - plugin manifest (with `id`, `version`, `ui`)
|
|
309
|
+
* @param {object} options
|
|
310
|
+
* @param {string} options.pluginDir - directory holding the plugin's files
|
|
311
|
+
* @param {'web-components'|'react'} options.target
|
|
312
|
+
* @param {string} [options.tokens] - the app's DESIGN.md (path or content); falls back to the plugin's ui.design_tokens
|
|
313
|
+
* @param {boolean} [options.typescript] - React wrappers as .tsx
|
|
314
|
+
* @returns {{ target, pluginId, files: Array<{ path, content, sha256 }>, elements, tokens, warnings, usage }}
|
|
315
|
+
*/
|
|
316
|
+
export function generateUiElements(manifest, { pluginDir, target, tokens, typescript = false } = {}) {
|
|
317
|
+
if (!UI_TARGETS.includes(target)) throw new Error(`Unknown UI target '${target}' (known: ${UI_TARGETS.join(', ')})`);
|
|
318
|
+
if (!manifest?.ui) throw new UiSpecError([`Plugin '${manifest?.id}' declares no ui`]);
|
|
319
|
+
const spec = normalizeUiSpec(manifest.ui);
|
|
320
|
+
if (!spec.elements.length) throw new UiSpecError([`Plugin '${manifest.id}' declares no ui elements`]);
|
|
321
|
+
const source = `${manifest.id}@${manifest.version}`;
|
|
322
|
+
const errors = [];
|
|
323
|
+
const warnings = [];
|
|
324
|
+
const files = [];
|
|
325
|
+
const add = (rel, content) => files.push({ path: rel, content, sha256: sha256(content) });
|
|
326
|
+
|
|
327
|
+
let tokenInfo = { source: null, variables: null, sha256: null };
|
|
328
|
+
let tokenContent = null;
|
|
329
|
+
if (tokens) tokenInfo.source = 'app';
|
|
330
|
+
else if (spec.design_tokens) tokenInfo.source = 'plugin';
|
|
331
|
+
if (tokenInfo.source) {
|
|
332
|
+
try {
|
|
333
|
+
tokenContent = tokens
|
|
334
|
+
? (!/[\r\n]/.test(tokens) && fs.existsSync(tokens) ? fs.readFileSync(tokens, 'utf8') : tokens)
|
|
335
|
+
: readPluginFile(pluginDir, spec.design_tokens, 'ui.design_tokens');
|
|
336
|
+
const parsed = parseDesignTokens(tokenContent);
|
|
337
|
+
tokenInfo = { ...tokenInfo, variables: parsed.variables, sha256: sha256(tokenContent) };
|
|
338
|
+
add('tokens.css', `/* Generated by Total Recall from the ${tokenInfo.source === 'app' ? "app's" : `plugin ${source}'s default`} DESIGN.md (sha256 ${tokenInfo.sha256.slice(0, 12)}). Edit DESIGN.md, then regenerate. */\n${parsed.css}`);
|
|
339
|
+
} catch (err) {
|
|
340
|
+
errors.push(`design tokens: ${err.message}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const elements = [];
|
|
345
|
+
for (const el of spec.elements) {
|
|
346
|
+
let code;
|
|
347
|
+
try {
|
|
348
|
+
code = readPluginFile(pluginDir, el.module, `element '${el.id}'`);
|
|
349
|
+
} catch (err) {
|
|
350
|
+
if (!(err instanceof UiElementError)) throw err;
|
|
351
|
+
errors.push(...err.errors);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
const check = validateUiElementSource(code, el);
|
|
355
|
+
errors.push(...check.errors);
|
|
356
|
+
warnings.push(...check.warnings);
|
|
357
|
+
if (tokenInfo.variables) {
|
|
358
|
+
const missing = el.tokens.filter((t) => !tokenInfo.variables.includes(t));
|
|
359
|
+
if (missing.length) errors.push(`element '${el.id}': the ${tokenInfo.source === 'app' ? "app's" : "plugin's default"} DESIGN.md does not define required tokens: ${missing.map((t) => `--${t}`).join(', ')}`);
|
|
360
|
+
const absent = el.optional_tokens.filter((t) => !tokenInfo.variables.includes(t));
|
|
361
|
+
if (absent.length) warnings.push(`element '${el.id}': optional tokens not defined, the element's fallbacks apply: ${absent.map((t) => `--${t}`).join(', ')}`);
|
|
362
|
+
}
|
|
363
|
+
add(`elements/${el.id}.js`, `// Generated by Total Recall from plugin ${source} (element ${el.id}, <${el.tag}>). Regenerate instead of editing.\n${code.endsWith('\n') ? code : `${code}\n`}`);
|
|
364
|
+
if (target === 'react' && typescript) {
|
|
365
|
+
// Lets a strict TypeScript app import the plain-JS element, and types the tag.
|
|
366
|
+
const fields = Object.entries(el.props).map(([prop, def]) => `${prop}${def.required ? '' : '?'}: ${TS_TYPES[def.type]}`);
|
|
367
|
+
add(`elements/${el.id}.d.ts`, [
|
|
368
|
+
`// Generated by Total Recall from plugin ${source} (element ${el.id}). Regenerate instead of editing.`,
|
|
369
|
+
'declare global {',
|
|
370
|
+
' interface HTMLElementTagNameMap {',
|
|
371
|
+
` ${JSON.stringify(el.tag)}: HTMLElement${fields.length ? ` & { ${fields.join('; ')} }` : ''};`,
|
|
372
|
+
' }',
|
|
373
|
+
'}',
|
|
374
|
+
'export {};',
|
|
375
|
+
''
|
|
376
|
+
].join('\n'));
|
|
377
|
+
}
|
|
378
|
+
if (target === 'react') {
|
|
379
|
+
const header = `// Generated by Total Recall from plugin ${source}: React wrapper for <${el.tag}>. Regenerate instead of editing.`;
|
|
380
|
+
add(`react/${pascal(el.id)}.${typescript ? 'tsx' : 'jsx'}`, reactWrapper(el, { typescript, header }));
|
|
381
|
+
}
|
|
382
|
+
elements.push({
|
|
383
|
+
id: el.id,
|
|
384
|
+
kind: el.kind,
|
|
385
|
+
tag: el.tag,
|
|
386
|
+
...(el.slot ? { slot: el.slot } : {}),
|
|
387
|
+
props: el.props,
|
|
388
|
+
events: el.events,
|
|
389
|
+
tokens: el.tokens,
|
|
390
|
+
optional_tokens: el.optional_tokens,
|
|
391
|
+
...(target === 'react' ? { react: { component: pascal(el.id), file: `react/${pascal(el.id)}.${typescript ? 'tsx' : 'jsx'}` } } : {})
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
if (errors.length) throw new UiElementError(errors);
|
|
395
|
+
|
|
396
|
+
add('index.js', `// Generated by Total Recall from plugin ${source}. Loads every element.\n${elements.map((el) => `import './elements/${el.id}.js';`).join('\n')}\n`);
|
|
397
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
398
|
+
const uiManifest = {
|
|
399
|
+
generated_by: 'total-recall',
|
|
400
|
+
plugin: { id: manifest.id, version: manifest.version },
|
|
401
|
+
target,
|
|
402
|
+
...(target === 'react' ? { typescript } : {}),
|
|
403
|
+
tokens: { source: tokenInfo.source, sha256: tokenInfo.sha256 },
|
|
404
|
+
elements,
|
|
405
|
+
files: Object.fromEntries(files.map((f) => [f.path, f.sha256]))
|
|
406
|
+
};
|
|
407
|
+
add(UI_MANIFEST_FILE, `${JSON.stringify(uiManifest, null, 2)}\n`);
|
|
408
|
+
|
|
409
|
+
const first = elements[0];
|
|
410
|
+
const hasTokens = Boolean(tokenInfo.source);
|
|
411
|
+
const usage = (target === 'react'
|
|
412
|
+
? [
|
|
413
|
+
hasTokens ? `import './${manifest.id}/tokens.css'; // once, e.g. in the root layout` : null,
|
|
414
|
+
`import ${first.react.component} from './${manifest.id}/${first.react.file.replace(/\.[jt]sx$/, '')}';`,
|
|
415
|
+
`<${first.react.component} ${Object.keys(first.props).slice(0, 1).map((p) => `${p}={…}`).join(' ')} />`
|
|
416
|
+
]
|
|
417
|
+
: [
|
|
418
|
+
hasTokens ? `<link rel="stylesheet" href="<static-base>/${manifest.id}/tokens.css">` : null,
|
|
419
|
+
`<script type="module" src="<static-base>/${manifest.id}/index.js"></script>`,
|
|
420
|
+
`<${first.tag}></${first.tag}>`
|
|
421
|
+
]).filter(Boolean).join('\n');
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
target,
|
|
425
|
+
pluginId: manifest.id,
|
|
426
|
+
files,
|
|
427
|
+
elements,
|
|
428
|
+
tokens: uiManifest.tokens,
|
|
429
|
+
warnings,
|
|
430
|
+
usage
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Writes generated files to `<outDir>/<pluginId>/`, replacing that directory
|
|
436
|
+
* whole and atomically. An existing directory is replaced only with `force`,
|
|
437
|
+
* and only when it was generated by Total Recall (it holds ui-manifest.json).
|
|
438
|
+
* @returns {string} the written directory
|
|
439
|
+
*/
|
|
440
|
+
export function writeUiElements(result, outDir, { force = false } = {}) {
|
|
441
|
+
const dest = path.resolve(outDir, result.pluginId);
|
|
442
|
+
if (fs.existsSync(dest)) {
|
|
443
|
+
if (!force) throw new Error(`${dest} exists; pass --force to regenerate it`);
|
|
444
|
+
if (!fs.existsSync(path.join(dest, UI_MANIFEST_FILE))) {
|
|
445
|
+
throw new Error(`${dest} exists and was not generated by Total Recall (no ${UI_MANIFEST_FILE}); refusing to replace it`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
449
|
+
const stage = fs.mkdtempSync(path.join(path.dirname(dest), `.${result.pluginId}.ui-`));
|
|
450
|
+
try {
|
|
451
|
+
for (const file of result.files) {
|
|
452
|
+
const full = path.join(stage, file.path);
|
|
453
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
454
|
+
fs.writeFileSync(full, file.content);
|
|
455
|
+
}
|
|
456
|
+
let old = null;
|
|
457
|
+
if (fs.existsSync(dest)) {
|
|
458
|
+
old = `${stage}.old`;
|
|
459
|
+
fs.renameSync(dest, old);
|
|
460
|
+
}
|
|
461
|
+
try {
|
|
462
|
+
fs.renameSync(stage, dest);
|
|
463
|
+
} catch (err) {
|
|
464
|
+
if (old) fs.renameSync(old, dest);
|
|
465
|
+
throw err;
|
|
466
|
+
}
|
|
467
|
+
if (old) fs.rmSync(old, { recursive: true, force: true });
|
|
468
|
+
} catch (err) {
|
|
469
|
+
fs.rmSync(stage, { recursive: true, force: true });
|
|
470
|
+
throw err;
|
|
471
|
+
}
|
|
472
|
+
return dest;
|
|
473
|
+
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
AppCapabilityInstallationSchema,
|
|
16
16
|
AccessGrantSchema
|
|
17
17
|
} from '../schema.mjs';
|
|
18
|
+
import { checkSkillLayerContract } from '../skill-config.mjs';
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Verifies an application's capability deployments, integrity, and conformance.
|
|
@@ -201,12 +202,29 @@ export async function verifyApplication(targetDir = process.cwd(), options = {})
|
|
|
201
202
|
}
|
|
202
203
|
}
|
|
203
204
|
|
|
205
|
+
// 4. Repo-layer contracts of layered skills (same check as `skill status`)
|
|
206
|
+
const skillsReport = [];
|
|
207
|
+
const skillsRoot = path.join(resolvedTarget, '.agent', 'skills');
|
|
208
|
+
if (fs.existsSync(skillsRoot)) {
|
|
209
|
+
for (const entry of fs.readdirSync(skillsRoot, { withFileTypes: true })) {
|
|
210
|
+
if (!entry.isDirectory()) continue;
|
|
211
|
+
const skillDir = path.join(skillsRoot, entry.name);
|
|
212
|
+
if (!fs.existsSync(path.join(skillDir, 'core', 'config.schema.json'))) continue;
|
|
213
|
+
const report = checkSkillLayerContract(skillDir);
|
|
214
|
+
skillsReport.push(report);
|
|
215
|
+
for (const failed of report.checks.filter((c) => !c.ok && c.level === 'error')) {
|
|
216
|
+
errors.push(`Skill '${report.skill_id}' contract: ${failed.message}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
204
221
|
return {
|
|
205
222
|
valid: errors.length === 0,
|
|
206
223
|
target: resolvedTarget,
|
|
207
224
|
vault: vaultDir,
|
|
208
225
|
capabilities: capabilitiesReport,
|
|
209
226
|
grants: grantsReport,
|
|
227
|
+
skills: skillsReport,
|
|
210
228
|
events_count: eventsCount,
|
|
211
229
|
errors
|
|
212
230
|
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Composable commands as an instruction-surface projection.
|
|
7
|
+
*
|
|
8
|
+
* Every compile rewrites the "Composable Commands" section from the command
|
|
9
|
+
* files themselves, so the surface always lists exactly the verbs that exist —
|
|
10
|
+
* one line each, never duplicated, gone when the file is removed. Files are
|
|
11
|
+
* parsed statically: compiling must never execute command code.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const NAME = /^[a-z][a-z0-9-]{0,63}$/;
|
|
15
|
+
const MAX_DESCRIPTION = 180;
|
|
16
|
+
|
|
17
|
+
function firstSentence(text) {
|
|
18
|
+
const clean = text.replace(/\s+/g, ' ').trim();
|
|
19
|
+
const sentence = clean.match(/^(.{12,}?[.!?])(\s|$)/)?.[1];
|
|
20
|
+
if (sentence && sentence.length <= MAX_DESCRIPTION) return sentence;
|
|
21
|
+
if (clean.length <= MAX_DESCRIPTION) return clean;
|
|
22
|
+
const cut = clean.slice(0, MAX_DESCRIPTION);
|
|
23
|
+
const stop = cut.lastIndexOf('. ');
|
|
24
|
+
return stop > 60 ? cut.slice(0, stop + 1) : `${cut.trimEnd()}…`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Read description and risk from a command file without importing it. */
|
|
28
|
+
export function describeCommandFile(source, name) {
|
|
29
|
+
const tag = (key) => source.match(new RegExp(`^\\s*//\\s*@${key}\\s+(.+)$`, 'm'))?.[1].trim() || null;
|
|
30
|
+
let description = tag('description');
|
|
31
|
+
const risk = tag('risk');
|
|
32
|
+
|
|
33
|
+
// Plugin-generated wrappers carry `const description = "<json string>";`
|
|
34
|
+
if (!description) {
|
|
35
|
+
const literal = source.match(/^const description = ("(?:[^"\\]|\\.)*");$/m)?.[1];
|
|
36
|
+
if (literal) { try { description = JSON.parse(literal) || null; } catch { /* ignore */ } }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Hand-written files: `// Custom CLI command: <name> — <description>` header,
|
|
40
|
+
// possibly continued on the following comment lines.
|
|
41
|
+
if (!description) {
|
|
42
|
+
const lines = source.split('\n');
|
|
43
|
+
const i = lines.findIndex((l) => new RegExp(`^//.*CLI command: ${name}\\s+[—–-]\\s+`).test(l));
|
|
44
|
+
if (i !== -1) {
|
|
45
|
+
const parts = [lines[i].replace(/^.*?CLI command: \S+\s+[—–-]\s+/, '')];
|
|
46
|
+
for (let j = i + 1; j < lines.length && lines[j].startsWith('//'); j++) parts.push(lines[j].replace(/^\/\/\s?/, ''));
|
|
47
|
+
description = parts.join(' ');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Inline commands: the leading comment block of the run() body. A first line
|
|
52
|
+
// that is a usage string (`total-recall <name> …`) is skipped.
|
|
53
|
+
if (!description) {
|
|
54
|
+
const body = source.split(/export async function run\([^)]*\)\s*\{/)[1] || '';
|
|
55
|
+
const comments = [];
|
|
56
|
+
for (const line of body.split('\n')) {
|
|
57
|
+
const t = line.trim();
|
|
58
|
+
if (!t || t.startsWith('const args = Array.isArray(argv)')) continue;
|
|
59
|
+
if (!t.startsWith('//')) break;
|
|
60
|
+
comments.push(t.replace(/^\/\/\s?/, ''));
|
|
61
|
+
}
|
|
62
|
+
const prose = comments.filter((c) => !c.startsWith(`total-recall ${name}`) && !c.startsWith('@'));
|
|
63
|
+
if (prose.length) description = prose.join(' ');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { description: description ? firstSentence(description) : null, risk };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* List commands from the given scope directories. Earlier dirs win on a name
|
|
71
|
+
* clash (the dispatcher resolves project before global), and the shadowed
|
|
72
|
+
* entry is reported so the surface can say so.
|
|
73
|
+
*/
|
|
74
|
+
export function listSurfaceCommands(dirs = []) {
|
|
75
|
+
const seen = new Map();
|
|
76
|
+
const out = [];
|
|
77
|
+
const visited = new Set();
|
|
78
|
+
for (const { scope, dir } of dirs) {
|
|
79
|
+
if (!dir) continue;
|
|
80
|
+
const real = path.resolve(dir);
|
|
81
|
+
if (visited.has(real)) continue;
|
|
82
|
+
visited.add(real);
|
|
83
|
+
let files = [];
|
|
84
|
+
try { files = fs.readdirSync(real).filter((f) => f.endsWith('.mjs')).sort(); } catch { continue; }
|
|
85
|
+
for (const file of files) {
|
|
86
|
+
const name = file.slice(0, -4);
|
|
87
|
+
if (!NAME.test(name)) continue;
|
|
88
|
+
const full = path.join(real, file);
|
|
89
|
+
let source = '';
|
|
90
|
+
try {
|
|
91
|
+
if (!fs.statSync(full).isFile()) continue;
|
|
92
|
+
source = fs.readFileSync(full, 'utf8');
|
|
93
|
+
} catch { continue; }
|
|
94
|
+
if (seen.has(name)) { seen.get(name).shadows.push(scope); continue; }
|
|
95
|
+
const entry = { name, scope, ...describeCommandFile(source, name), shadows: [] };
|
|
96
|
+
seen.set(name, entry);
|
|
97
|
+
out.push(entry);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function buildCommandsSection(entries = []) {
|
|
104
|
+
if (!entries.length) {
|
|
105
|
+
return `\n\n## Composable Commands\n\nNo custom commands yet. When you run the same steps twice, make them a verb: \`npx total-recall command create <name> "<js>" --description "<when to use it>"\` (add \`--global\` for every project).`;
|
|
106
|
+
}
|
|
107
|
+
const lines = entries.map((c) => {
|
|
108
|
+
const flags = [c.scope, c.risk ? `risk: ${c.risk}` : null, c.shadows.length ? `shadows ${c.shadows.join(', ')}` : null].filter(Boolean).join('; ');
|
|
109
|
+
return `- \`npx total-recall ${c.name}\` (${flags})${c.description ? ` — ${c.description}` : ''}`;
|
|
110
|
+
});
|
|
111
|
+
return `\n\n## Composable Commands\n\nVerbs built on this CLI with \`command create\`. Use them instead of re-doing their steps by hand; run \`npx total-recall <name> --help\` first. Add a new one when you repeat a sequence (\`command create <name> "<js>" --description "…" [--global]\`); this list is rebuilt from the command files on every compile.\n\n${lines.join('\n')}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Hash of every file that feeds the surface besides the vault (commands, skills). */
|
|
115
|
+
export function surfaceInputsHash({ commandDirs = [], skillsDir } = {}) {
|
|
116
|
+
const hash = crypto.createHash('sha256');
|
|
117
|
+
const add = (file) => {
|
|
118
|
+
try { const s = fs.statSync(file); hash.update(`${file}:${s.mtimeMs}:${s.size}\n`); } catch { /* missing */ }
|
|
119
|
+
};
|
|
120
|
+
for (const { dir } of commandDirs) {
|
|
121
|
+
if (!dir) continue;
|
|
122
|
+
try { fs.readdirSync(dir).filter((f) => f.endsWith('.mjs')).sort().forEach((f) => add(path.join(dir, f))); } catch { /* none */ }
|
|
123
|
+
hash.update(`dir:${dir}\n`);
|
|
124
|
+
}
|
|
125
|
+
if (skillsDir) {
|
|
126
|
+
try { fs.readdirSync(skillsDir).sort().forEach((e) => add(path.join(skillsDir, e, 'SKILL.md'))); } catch { /* none */ }
|
|
127
|
+
}
|
|
128
|
+
return hash.digest('hex');
|
|
129
|
+
}
|