Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/engine-parity.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,8 @@ jobs:
- name: Parity check contract
run: node scripts/test-check-engine-parity.mjs

- name: Coding-agent layer contract
run: node scripts/test-agent-layer.mjs

- name: Generated devDependencies and overrides match the pinned engine release
run: node scripts/check-engine-parity.mjs
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ Choose the themes:
Of course, you can combine the options, e.g.:
> npx neo-app -w workspace -n MyApp -t all

### Coding agents
Every new workspace is ready for a coding agent such as Claude Code or Codex:
- `AGENTS.md` (and a `CLAUDE.md` that imports it) explains how the engine works and where its source, guides and examples live in the workspace;
- the `neo-app-authoring` and `neural-link` skills are linked into `.agents/skills/` and `.claude/skills/` by the workspace's own `npm install`;
- each app's `neo-config.json` sets `useAiClient: "development"`, so the app connects to the Neural Link while you build it.

Leave all of that out:
> npx neo-app --no-agent

Add it to a workspace you already have (it changes nothing else, and keeps an existing `AGENTS.md` or `CLAUDE.md`):
> npx neo-app --agent-only -w workspace

## Starting a local web-server
By default, npx neo-app will start a webpack dev-server right after the build.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
},
"scripts" : {
"neo-app": "node ./tasks/neo-app.mjs",
"test" : "node scripts/test-check-engine-parity.mjs && node scripts/check-engine-parity.mjs"
"test" : "node scripts/test-check-engine-parity.mjs && node scripts/test-agent-layer.mjs && node scripts/check-engine-parity.mjs"
},
"bin" : {
"neo-app": "./tasks/neo-app.mjs"
Expand Down
21 changes: 18 additions & 3 deletions scripts/check-engine-parity.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,31 @@ export function generatedPackageJson() {
return packageJson
}

/**
* @summary DevDependencies both sides declare on purpose at different releases, each with the reason.
*
* `neo-agent-skills` serves two audiences. The engine pins the maintainer release it lints itself with, and a
* workspace installs the release whose skills carry the `app` audience. Equal ranges would be the defect here.
* @member {Map<String, String>}
*/
export const INDEPENDENT = new Map([
['neo-agent-skills', 'the engine pins its maintainer release; a workspace installs the app-audience release']
]);

/**
* @summary Compares the scaffold's devDependencies with the engine's.
* @param {Object} scaffold The generated devDependencies
* @param {Object} engine The engine release's devDependencies
* @returns {{drift: Object[], scaffoldOnly: String[]}} `drift` holds `{name, scaffold, engine}` for each differing range
* @returns {{drift: Object[], independent: String[], scaffoldOnly: String[]}} `drift` holds `{name, scaffold, engine}`
* for each differing range; `independent` lists the declared exceptions both sides carry
*/
export function findDrift(scaffold, engine) {
const names = Object.keys(scaffold);

return {
drift : names.filter(name => name in engine && scaffold[name] !== engine[name])
drift : names.filter(name => name in engine && !INDEPENDENT.has(name) && scaffold[name] !== engine[name])
.map(name => ({name, scaffold: scaffold[name], engine: engine[name]})),
independent : names.filter(name => name in engine && INDEPENDENT.has(name)),
scaffoldOnly: names.filter(name => !(name in engine))
}
}
Expand Down Expand Up @@ -92,13 +105,15 @@ if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToP
range = packageJson.dependencies['neo.mjs'],
// One matching version answers as a string, several as an ascending list
version = [].concat(npmView(`neo.mjs@${range}`, 'version')).at(-1),
{drift, scaffoldOnly} = findDrift(packageJson.devDependencies, npmView(`neo.mjs@${version}`, 'devDependencies')),
{drift, independent, scaffoldOnly} = findDrift(packageJson.devDependencies, npmView(`neo.mjs@${version}`, 'devDependencies')),
overrideDrift = findOverrideDrift(packageJson.overrides, npmView(`neo.mjs@${version}`, 'overrides'));

if (scaffoldOnly.length > 0) {
console.log(`check-engine-parity: not compared, no engine counterpart: ${scaffoldOnly.join(', ')}`)
}

independent.forEach(name => console.log(`check-engine-parity: not compared, independent by design: ${name} (${INDEPENDENT.get(name)})`));

if (drift.length > 0) {
console.error(`check-engine-parity: ${drift.length} generated devDependencies differ from neo.mjs@${version} ` +
`(the newest release \`${range}\` allows):`);
Expand Down
97 changes: 97 additions & 0 deletions scripts/test-agent-layer.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env node
/**
* @summary Contract checks for the coding-agent layer: what a new workspace gets with and without it, and what
* `--agent-only` adds to an existing one — against real files in a throwaway directory, with no install.
*/

import assert from 'node:assert/strict';
// node:fs, not fs-extra: the CI job runs this with no install, and the tasks need only the plain calls.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import createAgentLayer, {AGENT_SKILLS_RANGE, AI_CLIENT_ENVIRONMENT} from '../tasks/createAgentLayer.mjs';
import createNeoConfigJson from '../tasks/createNeoConfigJson.mjs';
import createPackageJson from '../tasks/createPackageJson.mjs';

const
root = fs.mkdtempSync(path.join(os.tmpdir(), 'neo-app-agent-')),
read = (...parts) => JSON.parse(fs.readFileSync(path.join(...parts), 'utf-8')),
scaffold = (name, agent) => {
const workspace = path.join(root, name),
appPath = path.join(workspace, 'apps', 'myapp');

fs.mkdirSync(appPath, {recursive: true});
createPackageJson.init('MyApp', workspace, fs, os, path, agent);
createNeoConfigJson.init('MyApp', appPath, fs, ['DragDrop', 'Navigator', 'Stylesheet'], os, path, ['all'], 'no', 'no', agent);
agent && createAgentLayer.writeClaudeMd(workspace, fs, path);

return workspace
};

try {
// ── A new workspace gets the layer by default ────────────────────────────────────────────────
{
const workspace = scaffold('with', true),
packageJson = read(workspace, 'package.json');

assert.equal(packageJson.scripts.prepare, 'neo-agent-skills-materialize --audience app', 'its install links the app skills');
assert.equal(packageJson.devDependencies['neo-agent-skills'], AGENT_SKILLS_RANGE, 'from the release that declares them');
assert.deepEqual(Object.keys(packageJson.devDependencies), [...Object.keys(packageJson.devDependencies)].sort(), 'devDependencies stay alphabetical');
assert.deepEqual(Object.keys(packageJson.scripts), [...Object.keys(packageJson.scripts)].sort(), 'and so do the scripts');
assert.equal(read(workspace, 'apps', 'myapp', 'neo-config.json').useAiClient, AI_CLIENT_ENVIRONMENT, 'the app connects to the Neural Link in development');
assert.equal(fs.readFileSync(path.join(workspace, 'CLAUDE.md'), 'utf-8'), '@AGENTS.md\n', 'and CLAUDE.md only imports AGENTS.md');
}

// ── --no-agent writes none of it ─────────────────────────────────────────────────────────────
{
const workspace = scaffold('without', false),
packageJson = read(workspace, 'package.json');

assert.equal(packageJson.scripts.prepare, undefined, 'no prepare');
assert.equal(packageJson.devDependencies['neo-agent-skills'], undefined, 'no skills dependency');
assert.equal(read(workspace, 'apps', 'myapp', 'neo-config.json').useAiClient, undefined, 'no Neural Link client');
assert.ok(!fs.existsSync(path.join(workspace, 'CLAUDE.md')), 'no CLAUDE.md');
}

// ── --agent-only adds the layer to an existing workspace, and only once ──────────────────────
{
const workspace = scaffold('existing', false),
other = path.join(workspace, 'apps', 'other');

fs.mkdirSync(other, {recursive: true});
fs.writeFileSync(path.join(other, 'neo-config.json'), JSON.stringify({useAiClient: false}, null, 4));
fs.writeFileSync(path.join(workspace, 'README.md'), 'mine\n');

const changed = createAgentLayer.addToWorkspace(workspace, fs, os, path);

assert.deepEqual(changed.sort(), ['CLAUDE.md', path.join('apps', 'myapp', 'neo-config.json'), 'package.json'].sort(),
'it changes the package, the app without the switch, and adds CLAUDE.md');
assert.equal(read(other, 'neo-config.json').useAiClient, false, 'an app that already sets useAiClient keeps its choice');
assert.equal(fs.readFileSync(path.join(workspace, 'README.md'), 'utf-8'), 'mine\n', 'and no other file changes');
assert.deepEqual(createAgentLayer.addToWorkspace(workspace, fs, os, path), [], 'a second run changes nothing');
}

// ── AGENTS.md: an existing file is the workspace's, never regenerated ───────────────────────
{
const workspace = path.join(root, 'with');

fs.writeFileSync(path.join(workspace, 'AGENTS.md'), 'edited\n');

const spawned = [];

assert.equal(createAgentLayer.generateAgentsMd(workspace, fs, path, (...args) => {spawned.push(args); return {status: 0}}, 'npx'), false);
assert.equal(spawned.length, 0, 'the generator is not even started');
assert.equal(fs.readFileSync(path.join(workspace, 'AGENTS.md'), 'utf-8'), 'edited\n');
}

// ── A generator that fails is a loud failure, not a missing file ────────────────────────────
{
const workspace = path.join(root, 'without');

assert.throws(() => createAgentLayer.generateAgentsMd(workspace, fs, path, () => ({status: 1}), 'npx'), /AGENTS\.md generator exited 1/);
}
} finally {
fs.rmSync(root, {force: true, recursive: true});
}

console.log('agent-layer: contract arms green.');
12 changes: 12 additions & 0 deletions scripts/test-check-engine-parity.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ import {findDrift, findOverrideDrift, generatedPackageJson} from './check-engine
assert.deepEqual(scaffoldOnly, ['chromadb'])
}

// ── A declared independent range is listed, never drift — and only the declared one ──────────────────
{
const {drift, independent} = findDrift(
{marked: '^17.0.4', 'neo-agent-skills': '^0.1.60'},
{marked: '^18.0.13', 'neo-agent-skills': '0.1.56'}
);

assert.deepEqual(independent, ['neo-agent-skills'], 'the skills package serves another audience in the workspace');
assert.deepEqual(drift.map(({name}) => name), ['marked'], 'while an undeclared difference beside it is still drift');
assert.ok(generatedPackageJson().devDependencies['neo-agent-skills'], 'and the scaffold really declares it');
}

// ── Overrides compare in full, both ways: npm reads them only in the root project ──────────────────
{
const engine = {'gray-matter': {'js-yaml': '^3.15.2'}, 'lodash-es': '^4.18.0'};
Expand Down
134 changes: 134 additions & 0 deletions tasks/createAgentLayer.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* @summary The coding-agent layer of a workspace: what an agent working in it reads first, and the skills it gets.
*
* A workspace `neo-app` creates is usually built by a coding agent, and an agent that knows nothing about the
* engine writes React into it. The layer is four things, each from its single source:
*
* - `AGENTS.md`, emitted by the installed `neo-agent-skills` generator for the `neo-app-workspace` kind, after the
* workspace's own install. It is written once and then belongs to the workspace, so later installs never
* overwrite an edited copy.
* - `CLAUDE.md`, which only imports `AGENTS.md`: Claude Code reads its own file name.
* - the `app` skills, linked into `.agents/skills` and `.claude/skills` by the workspace's `prepare` on every install,
* so they update with the package.
* - `useAiClient` in each app's `neo-config.json`, set to the development environment, so the app connects to the
* Neural Link while it is being built and never in a production build.
*/

/**
* @summary The release whose skills carry the `app` audience.
* @member {String}
*/
export const AGENT_SKILLS_RANGE = '^0.1.61';

/**
* @summary The environment in which a scaffolded app connects to the Neural Link.
* @member {String}
*/
export const AI_CLIENT_ENVIRONMENT = 'development';

/**
* @summary The scripts the layer adds to a workspace's `package.json`.
* @member {Object}
*/
export const agentScripts = {
prepare: 'neo-agent-skills-materialize --audience app'
};

const
CLAUDE_MD = '@AGENTS.md\n',
// The generator's declared kind for a workspace `neo-app` creates; never one of the engine's own repositories.
AGENTS_MD_ARGS = ['--no-install', 'neo-agent-skills-agents-md', '--repo', 'neo-app-workspace', '--audience', 'app', '--out', 'AGENTS.md'];

export default {
/**
* @summary Writes `CLAUDE.md` into a workspace that has none.
* @param {String} workspace
* @param {Object} fs
* @param {Object} path
* @returns {Boolean} Whether the file was written
*/
writeClaudeMd(workspace, fs, path) {
const file = path.join(workspace, 'CLAUDE.md');

if (fs.existsSync(file)) {
return false
}

fs.writeFileSync(file, CLAUDE_MD);
return true
},

/**
* @summary Emits `AGENTS.md` from the workspace's installed skills package, unless the workspace already has one.
* Run after the workspace's install: the generator is that install's `neo-agent-skills` binary.
* @param {String} workspace
* @param {Object} fs
* @param {Object} path
* @param {Function} spawnSync
* @param {String} npxCmd
* @returns {Boolean} Whether the file was written
*/
generateAgentsMd(workspace, fs, path, spawnSync, npxCmd) {
if (fs.existsSync(path.join(workspace, 'AGENTS.md'))) {
return false
}

const result = spawnSync(npxCmd, AGENTS_MD_ARGS, {cwd: workspace, encoding: 'utf-8', shell: true, stdio: 'inherit'});

if (result.status !== 0) {
throw new Error(`neo-app: the AGENTS.md generator exited ${result.status ?? result.error?.message}; is neo-agent-skills ${AGENT_SKILLS_RANGE} installed?`)
}

return true
},

/**
* @summary Adds the layer to an existing workspace (`--agent-only`): the skills dependency and its `prepare`, and
* `useAiClient` in every app that does not set it. A key the workspace already sets is left as it is.
* @param {String} workspace
* @param {Object} fs
* @param {Object} os
* @param {Object} path
* @returns {String[]} The files it changed, relative to the workspace
*/
addToWorkspace(workspace, fs, os, path) {
const
changed = [],
packageJsonPath = path.join(workspace, 'package.json'),
packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));

packageJson.scripts ??= {};
packageJson.devDependencies ??= {};

if (!packageJson.scripts.prepare && !packageJson.devDependencies['neo-agent-skills']) {
Object.assign(packageJson.scripts, agentScripts);
packageJson.devDependencies['neo-agent-skills'] = AGENT_SKILLS_RANGE;
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 4) + os.EOL);
changed.push('package.json')
}

const appsDir = path.join(workspace, 'apps');

for (const app of fs.existsSync(appsDir) ? fs.readdirSync(appsDir) : []) {
const configPath = path.join(appsDir, app, 'neo-config.json');

if (!fs.existsSync(configPath)) {
continue
}

const neoConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));

if (!('useAiClient' in neoConfig)) {
neoConfig.useAiClient = AI_CLIENT_ENVIRONMENT;
fs.writeFileSync(configPath, JSON.stringify(neoConfig, null, 4));
changed.push(path.join('apps', app, 'neo-config.json'))
}
}

if (this.writeClaudeMd(workspace, fs, path)) {
changed.push('CLAUDE.md')
}

return changed
}
};
4 changes: 4 additions & 0 deletions tasks/createGitignore.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export default {
"test/playwright/test-results/",
"# Single-file Playwright runs via npx",
"test-results/",
"",
"# Coding-agent skills: links into node_modules, refreshed by every install",
".agents/skills/",
".claude/skills/",
].join(os.EOL);

fs.writeFileSync(path.join(folder, '.gitignore'), gitignoreContent);
Expand Down
20 changes: 19 additions & 1 deletion tasks/createNeoConfigJson.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
import {AI_CLIENT_ENVIRONMENT} from './createAgentLayer.mjs';

export default {
init(appName, folder, fs, mainThreadAddons, os, path, themes, useSharedWorkers, useServiceWorker) {
/**
* @param {String} appName
* @param {String} folder
* @param {Object} fs
* @param {String[]} mainThreadAddons
* @param {Object} os
* @param {Object} path
* @param {String[]} themes
* @param {String} useSharedWorkers
* @param {String} useServiceWorker
* @param {Boolean} [agent=true] Let the app connect to the Neural Link in development, so a coding agent can verify it live
*/
init(appName, folder, fs, mainThreadAddons, os, path, themes, useSharedWorkers, useServiceWorker, agent = true) {
let neoConfig = {
appPath : `../../apps/${appName.toLowerCase()}/app.mjs`,
basePath : '../../',
Expand Down Expand Up @@ -33,6 +47,10 @@ export default {
neoConfig.useServiceWorker = true;
}

if (agent) {
neoConfig.useAiClient = AI_CLIENT_ENVIRONMENT;
}

let configs = Object.entries(neoConfig).sort((a, b) => a[0].localeCompare(b[0]));
neoConfig = {};

Expand Down
Loading
Loading