refactor(cli): single-package workspace init and root dist build (#274)
Init templates match ~/.uncaged-nerve: scripts/build.mjs writes dist/senses/*/index.js and dist/workflows/*/index.js; drop @uncaged/nerve-skills from generated package.json; refresh Cursor skills rule copy. Sense worker sends full compute result on signal IPC so the kernel can route workflow triggers; update e2e harness paths (migrations under senses/, noop under dist/workflows). Fixes #274 Made-with: Cursor
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
"scripts": {
|
||||
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
|
||||
"build": "rslib build",
|
||||
"pretest": "pnpm --filter @uncaged/nerve-core run build && pnpm --filter @uncaged/nerve-daemon run build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildSenseIndexTs,
|
||||
buildSenseMigrationSql,
|
||||
buildSensePackageJson,
|
||||
buildSenseSchemaTs,
|
||||
validateResourceName,
|
||||
} from "../commands/create.js";
|
||||
@@ -46,20 +45,11 @@ describe("buildSenseMigrationSql", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSensePackageJson", () => {
|
||||
it("includes esbuild script and sense name", () => {
|
||||
const pkg = JSON.parse(buildSensePackageJson("my-sense"));
|
||||
expect(pkg.name).toBe("nerve-sense-my-sense");
|
||||
expect(pkg.scripts.build).toContain("esbuild");
|
||||
expect(pkg.scripts.build).toContain("src/index.ts");
|
||||
expect(pkg.devDependencies.esbuild).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSenseIndexTs", () => {
|
||||
it("embeds sense id in stub with TypeScript types", () => {
|
||||
const ts = buildSenseIndexTs("my-sense");
|
||||
expect(ts).toContain("my-sense");
|
||||
expect(ts).toContain("export { mySense as table }");
|
||||
expect(ts).toContain("export async function compute");
|
||||
expect(ts).toContain("LibSQLDatabase");
|
||||
expect(ts).toContain("Promise<SenseResult>");
|
||||
|
||||
@@ -9,7 +9,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { buildWorkflowPackageJson, buildWorkflowScaffold } from "../commands/create.js";
|
||||
import { buildWorkflowScaffold } from "../commands/create.js";
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
@@ -81,21 +81,6 @@ describe("buildWorkflowScaffold", () => {
|
||||
const { roleMainPromptMd } = buildWorkflowScaffold("my-flow");
|
||||
expect(roleMainPromptMd).toContain("# my-flow — main role");
|
||||
});
|
||||
|
||||
it("package.json defines esbuild bundling to dist/", () => {
|
||||
const pkg = JSON.parse(buildWorkflowPackageJson("my-flow")) as {
|
||||
scripts: { build: string };
|
||||
devDependencies: { esbuild: string };
|
||||
};
|
||||
expect(pkg.scripts.build).toContain("esbuild");
|
||||
expect(pkg.scripts.build).toContain("--outdir=dist");
|
||||
expect(pkg.devDependencies.esbuild).toBeTruthy();
|
||||
});
|
||||
|
||||
it("buildWorkflowScaffold includes package.json body", () => {
|
||||
const { packageJson } = buildWorkflowScaffold("wf");
|
||||
expect(JSON.parse(packageJson).scripts.build).toContain("esbuild");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow scaffold file writing (simulated)", () => {
|
||||
|
||||
@@ -122,54 +122,49 @@ describe("e2e create", () => {
|
||||
});
|
||||
|
||||
it(
|
||||
"create workflow scaffolds sources and package.json with esbuild build",
|
||||
{ timeout: 10_000 },
|
||||
"create workflow scaffolds sources and root build emits dist/workflows/<name>/index.js",
|
||||
{ timeout: 120_000 },
|
||||
async () => {
|
||||
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
|
||||
const nerveRoot = join(fakeHome, ".uncaged-nerve");
|
||||
|
||||
await runTestCli(fakeHome, ["init", "--force", "--skip-install"]);
|
||||
await runTestCli(fakeHome, ["init", "--force"]);
|
||||
|
||||
const wf = await runTestCli(fakeHome, ["create", "workflow", "e2e-flow"]);
|
||||
expect(wf.exitCode).toBe(0);
|
||||
expect(wf.stdout).toContain("✅");
|
||||
|
||||
const pkgPath = join(nerveRoot, "workflows", "e2e-flow", "package.json");
|
||||
const indexPath = join(nerveRoot, "workflows", "e2e-flow", "index.ts");
|
||||
const mainRolePath = join(nerveRoot, "workflows", "e2e-flow", "roles", "main", "index.ts");
|
||||
expect(existsSync(pkgPath)).toBe(true);
|
||||
expect(JSON.parse(readFileSync(pkgPath, "utf8")).scripts.build).toContain("esbuild");
|
||||
const wfDir = join(nerveRoot, "workflows", "e2e-flow");
|
||||
const indexPath = join(wfDir, "index.ts");
|
||||
const mainRolePath = join(wfDir, "roles", "main", "index.ts");
|
||||
expect(existsSync(join(wfDir, "package.json"))).toBe(false);
|
||||
expect(existsSync(indexPath)).toBe(true);
|
||||
expect(existsSync(mainRolePath)).toBe(true);
|
||||
expect(readFileSync(indexPath, "utf8")).toContain('name: "e2e-flow"');
|
||||
expect(readFileSync(mainRolePath, "utf8")).toContain("e2e-flow started");
|
||||
expect(existsSync(join(nerveRoot, "dist", "workflows", "e2e-flow", "index.js"))).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"create sense scaffolds src/index.ts, src/schema.ts, package.json and migration",
|
||||
{ timeout: 60_000 },
|
||||
"create sense scaffolds src/, migration, and root build emits dist/senses/<name>/index.js",
|
||||
{ timeout: 120_000 },
|
||||
async () => {
|
||||
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
|
||||
const nerveRoot = join(fakeHome, ".uncaged-nerve");
|
||||
|
||||
await runTestCli(fakeHome, ["init", "--force", "--skip-install"]);
|
||||
await runTestCli(fakeHome, ["init", "--force"]);
|
||||
|
||||
const sense = await runTestCli(fakeHome, ["create", "sense", "e2e-sense"]);
|
||||
expect(sense.exitCode).toBe(0);
|
||||
expect(sense.stdout).toContain("✅");
|
||||
|
||||
const base = join(nerveRoot, "senses", "e2e-sense");
|
||||
expect(existsSync(join(base, "package.json"))).toBe(true);
|
||||
expect(existsSync(join(base, "package.json"))).toBe(false);
|
||||
expect(existsSync(join(base, "src", "index.ts"))).toBe(true);
|
||||
expect(existsSync(join(base, "src", "schema.ts"))).toBe(true);
|
||||
expect(existsSync(join(base, "migrations", "0001_init.sql"))).toBe(true);
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(base, "package.json"), "utf8"));
|
||||
expect(pkg.scripts.build).toContain("esbuild");
|
||||
|
||||
// pnpm install + build should produce index.js
|
||||
expect(existsSync(join(base, "index.js"))).toBe(true);
|
||||
expect(existsSync(join(nerveRoot, "dist", "senses", "e2e-sense", "index.js"))).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -37,7 +37,15 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
@@ -61,6 +69,27 @@ const nerveDaemonRoot = dirname(require.resolve("@uncaged/nerve-daemon/package.j
|
||||
const senseWorkerScript = join(nerveDaemonRoot, "dist", "sense-worker.js");
|
||||
const workflowWorkerScript = join(nerveDaemonRoot, "dist", "workflow-worker.js");
|
||||
|
||||
function resolveDrizzleOrmPackageRoot(): string {
|
||||
const requireFromDaemon = createRequire(join(nerveDaemonRoot, "package.json"));
|
||||
const entry = requireFromDaemon.resolve("drizzle-orm");
|
||||
let dir = dirname(entry);
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
const pkgPath = join(dir, "package.json");
|
||||
if (existsSync(pkgPath)) {
|
||||
try {
|
||||
const name = (JSON.parse(readFileSync(pkgPath, "utf8")) as { name: string }).name;
|
||||
if (name === "drizzle-orm") return dir;
|
||||
} catch {
|
||||
// keep walking
|
||||
}
|
||||
}
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
throw new Error("Could not resolve drizzle-orm package root for e2e harness");
|
||||
}
|
||||
|
||||
const nerveYamlTemplate = `senses:
|
||||
counter:
|
||||
group: e2e
|
||||
@@ -88,9 +117,9 @@ const echoWorkflowIndexJs = `const END = "__end__";
|
||||
export default {
|
||||
name: "echo",
|
||||
roles: {
|
||||
echo: async (start, _messages) => {
|
||||
echo: async (ctx) => {
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
const p = typeof start.content === "string" ? start.content : "";
|
||||
const p = typeof ctx.start.content === "string" ? ctx.start.content : "";
|
||||
return {
|
||||
content: p.length > 0 ? "echo:" + p : "echo:empty",
|
||||
meta: {},
|
||||
@@ -121,17 +150,30 @@ api:
|
||||
host: 127.0.0.1
|
||||
`;
|
||||
|
||||
/** Empty migration — counter sense uses only `_signals` (auto-created by daemon). */
|
||||
const counterMigration = `-- no-op migration for e2e counter sense
|
||||
SELECT 1;
|
||||
/** Schema for sense signal rows persisted via \`db.insert(table)\` (see sense-runtime). */
|
||||
const counterMigration = `CREATE TABLE IF NOT EXISTS counter_signals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
count INTEGER,
|
||||
launched INTEGER,
|
||||
idle INTEGER
|
||||
);
|
||||
`;
|
||||
|
||||
/**
|
||||
* Minimal counter sense — each compute returns an incrementing count.
|
||||
* Does NOT touch the DB directly; signal persistence is handled by the daemon
|
||||
* (`runtime.persistSignal`) which writes to `_signals` automatically.
|
||||
* Does NOT touch the DB directly in compute(); the daemon inserts into \`table\`
|
||||
* and persistSignal handles \`_signals\`.
|
||||
*/
|
||||
const counterIndexJs = `let _count = 0;
|
||||
const counterIndexJs = `import { integer, sqliteTable } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const table = sqliteTable("counter_signals", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
count: integer("count"),
|
||||
launched: integer("launched"),
|
||||
idle: integer("idle"),
|
||||
});
|
||||
|
||||
let _count = 0;
|
||||
export async function compute(_db, _peers, _options) {
|
||||
_count += 1;
|
||||
return { signal: { count: _count }, workflow: null };
|
||||
@@ -139,12 +181,21 @@ export async function compute(_db, _peers, _options) {
|
||||
`;
|
||||
|
||||
/** First trigger launches local noop workflow; later triggers emit a plain signal. */
|
||||
const counterIndexJsWithNoopWorkflow = `let _launched = false;
|
||||
const counterIndexJsWithNoopWorkflow = `import { integer, sqliteTable } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const table = sqliteTable("counter_signals", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
count: integer("count"),
|
||||
launched: integer("launched"),
|
||||
idle: integer("idle"),
|
||||
});
|
||||
|
||||
let _launched = false;
|
||||
export async function compute(_db, _peers, _options) {
|
||||
if (!_launched) {
|
||||
_launched = true;
|
||||
return {
|
||||
signal: { launched: true },
|
||||
signal: { launched: 1 },
|
||||
workflow: {
|
||||
name: "noop",
|
||||
maxRounds: 3,
|
||||
@@ -153,7 +204,7 @@ export async function compute(_db, _peers, _options) {
|
||||
},
|
||||
};
|
||||
}
|
||||
return { signal: { idle: true }, workflow: null };
|
||||
return { signal: { idle: 1 }, workflow: null };
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -209,7 +260,8 @@ function writeWorkspaceLayout(nerveRoot: string, withNoopWorkflow: boolean): voi
|
||||
mkdirSync(join(nerveRoot, "data", "senses"), { recursive: true });
|
||||
mkdirSync(join(nerveRoot, "data", "blobs"), { recursive: true });
|
||||
mkdirSync(join(nerveRoot, "senses", "counter", "migrations"), { recursive: true });
|
||||
mkdirSync(join(nerveRoot, "workflows", "echo", "dist"), { recursive: true });
|
||||
mkdirSync(join(nerveRoot, "dist", "senses", "counter"), { recursive: true });
|
||||
mkdirSync(join(nerveRoot, "dist", "workflows", "echo"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(nerveRoot, "nerve.yaml"),
|
||||
withNoopWorkflow ? nerveYamlWithNoopWorkflow : nerveYamlTemplate,
|
||||
@@ -221,20 +273,19 @@ function writeWorkspaceLayout(nerveRoot: string, withNoopWorkflow: boolean): voi
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
join(nerveRoot, "senses", "counter", "index.js"),
|
||||
join(nerveRoot, "dist", "senses", "counter", "index.js"),
|
||||
withNoopWorkflow ? counterIndexJsWithNoopWorkflow : counterIndexJs,
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
join(nerveRoot, "workflows", "echo", "dist", "index.js"),
|
||||
join(nerveRoot, "dist", "workflows", "echo", "index.js"),
|
||||
echoWorkflowIndexJs,
|
||||
"utf8",
|
||||
);
|
||||
if (withNoopWorkflow) {
|
||||
mkdirSync(join(nerveRoot, "workflows", "noop", "dist"), { recursive: true });
|
||||
mkdirSync(join(nerveRoot, "workflows", "noop", "migrations"), { recursive: true });
|
||||
mkdirSync(join(nerveRoot, "dist", "workflows", "noop"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(nerveRoot, "workflows", "noop", "dist", "index.js"),
|
||||
join(nerveRoot, "dist", "workflows", "noop", "index.js"),
|
||||
noopWorkflowIndexJs,
|
||||
"utf8",
|
||||
);
|
||||
@@ -267,11 +318,17 @@ function useNoopWorkflow(opts: StartTestDaemonOpts): boolean {
|
||||
*/
|
||||
export function linkWorkspaceDaemonIntoNerveRoot(nerveRoot: string): void {
|
||||
const daemonPkgRoot = dirname(require.resolve("@uncaged/nerve-daemon/package.json"));
|
||||
const linkDir = join(nerveRoot, "node_modules", "@uncaged");
|
||||
const linkPath = join(linkDir, "nerve-daemon");
|
||||
const nm = join(nerveRoot, "node_modules");
|
||||
mkdirSync(nm, { recursive: true });
|
||||
|
||||
const linkDir = join(nm, "@uncaged");
|
||||
mkdirSync(linkDir, { recursive: true });
|
||||
if (existsSync(linkPath)) return;
|
||||
symlinkSync(daemonPkgRoot, linkPath);
|
||||
const linkPath = join(linkDir, "nerve-daemon");
|
||||
if (!existsSync(linkPath)) symlinkSync(daemonPkgRoot, linkPath);
|
||||
|
||||
const drizzlePkgRoot = resolveDrizzleOrmPackageRoot();
|
||||
const drizzleLink = join(nm, "drizzle-orm");
|
||||
if (!existsSync(drizzleLink)) symlinkSync(drizzlePkgRoot, drizzleLink);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -202,10 +202,9 @@ describe("e2e init", () => {
|
||||
// Verify key files exist
|
||||
expect(existsSync(join(nerveRoot, "nerve.yaml"))).toBe(true);
|
||||
expect(existsSync(join(nerveRoot, "package.json"))).toBe(true);
|
||||
expect(existsSync(join(nerveRoot, "pnpm-workspace.yaml"))).toBe(true);
|
||||
expect(existsSync(join(nerveRoot, "scripts", "build.mjs"))).toBe(true);
|
||||
expect(existsSync(join(nerveRoot, "biome.json"))).toBe(true);
|
||||
expect(existsSync(join(nerveRoot, ".gitignore"))).toBe(true);
|
||||
expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "package.json"))).toBe(true);
|
||||
expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "src", "index.ts"))).toBe(true);
|
||||
expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "src", "schema.ts"))).toBe(true);
|
||||
expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "migrations", "0001_init.sql"))).toBe(
|
||||
@@ -214,19 +213,14 @@ describe("e2e init", () => {
|
||||
expect(existsSync(join(nerveRoot, ".cursor", "rules", "nerve-skills.mdc"))).toBe(true);
|
||||
|
||||
const pkgJson = readFileSync(join(nerveRoot, "package.json"), "utf8");
|
||||
expect(pkgJson).toContain('"@uncaged/nerve-skills": "latest"');
|
||||
expect(pkgJson).toContain('"build": "pnpm -r build"');
|
||||
expect(pkgJson).not.toContain("nerve-skills");
|
||||
expect(pkgJson).toContain('"build": "node scripts/build.mjs"');
|
||||
expect(pkgJson).toContain('"esbuild": "^0.27.0"');
|
||||
|
||||
const workspaceYaml = readFileSync(join(nerveRoot, "pnpm-workspace.yaml"), "utf8");
|
||||
expect(workspaceYaml).toContain("workflows/*");
|
||||
expect(workspaceYaml).toContain("senses/*");
|
||||
|
||||
const sensePkgJson = readFileSync(
|
||||
join(nerveRoot, "senses", "cpu-usage", "package.json"),
|
||||
"utf8",
|
||||
);
|
||||
expect(sensePkgJson).toContain("nerve-sense-cpu-usage");
|
||||
expect(sensePkgJson).toContain("esbuild");
|
||||
const buildScript = readFileSync(join(nerveRoot, "scripts", "build.mjs"), "utf8");
|
||||
expect(buildScript).toContain('path.join(root, "senses")');
|
||||
expect(buildScript).toContain('path.join(root, "workflows")');
|
||||
expect(buildScript).toContain("dist");
|
||||
});
|
||||
|
||||
it("generated nerve.yaml passes validate", { timeout: 10_000 }, async () => {
|
||||
|
||||
@@ -20,34 +20,13 @@ export type WorkflowScaffoldFiles = {
|
||||
indexTs: string;
|
||||
roleMainIndexTs: string;
|
||||
roleMainPromptMd: string;
|
||||
packageJson: string;
|
||||
};
|
||||
|
||||
export function buildWorkflowPackageJson(name: string): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
name: `nerve-workflow-${name}`,
|
||||
private: true,
|
||||
type: "module",
|
||||
scripts: {
|
||||
build:
|
||||
"esbuild index.ts --bundle --platform=node --format=esm --outdir=dist --packages=external",
|
||||
},
|
||||
devDependencies: {
|
||||
esbuild: "^0.27.0",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
export function buildWorkflowScaffold(name: string): WorkflowScaffoldFiles {
|
||||
return {
|
||||
indexTs: buildWorkflowIndexTs(name),
|
||||
roleMainIndexTs: buildWorkflowMainRoleIndexTs(name),
|
||||
roleMainPromptMd: buildWorkflowMainRolePromptMd(name),
|
||||
packageJson: buildWorkflowPackageJson(name),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,32 +111,14 @@ export const ${exportName} = sqliteTable("${table}", {
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildSensePackageJson(name: string): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
name: `nerve-sense-${name}`,
|
||||
private: true,
|
||||
type: "module",
|
||||
scripts: {
|
||||
build:
|
||||
"esbuild src/index.ts --bundle --platform=node --format=esm --outdir=. --out-extension:.js=.js --packages=external",
|
||||
},
|
||||
devDependencies: {
|
||||
esbuild: "^0.27.0",
|
||||
"drizzle-orm": "*",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
export function buildSenseIndexTs(senseId: string): string {
|
||||
const exportName = senseIdToSchemaExportName(senseId);
|
||||
return `import type { LibSQLDatabase } from "drizzle-orm/libsql";
|
||||
|
||||
import { ${exportName} } from "./schema.js";
|
||||
|
||||
export { ${exportName} as table } from "./schema.js";
|
||||
|
||||
type SenseResult = {
|
||||
signal: { label: string; ts: number };
|
||||
workflow: null;
|
||||
@@ -245,30 +206,39 @@ const createWorkflowCommand = defineCommand({
|
||||
|
||||
mkdirSync(workflowDir, { recursive: true });
|
||||
const scaffold = buildWorkflowScaffold(args.name);
|
||||
writeFile(join(workflowDir, "package.json"), scaffold.packageJson);
|
||||
writeFile(join(workflowDir, "index.ts"), scaffold.indexTs);
|
||||
writeFile(join(workflowDir, "roles", "main", "index.ts"), scaffold.roleMainIndexTs);
|
||||
writeFile(join(workflowDir, "roles", "main", "prompt.md"), scaffold.roleMainPromptMd);
|
||||
|
||||
process.stdout.write("✅ Workflow scaffolded:\n");
|
||||
process.stdout.write(` ${join(workflowDir, "package.json")}\n`);
|
||||
process.stdout.write(` ${join(workflowDir, "index.ts")}\n`);
|
||||
process.stdout.write(` ${join(workflowDir, "roles", "main", "index.ts")}\n`);
|
||||
process.stdout.write(` ${join(workflowDir, "roles", "main", "prompt.md")}\n`);
|
||||
|
||||
process.stdout.write("\nBuilding workspace (workflows + senses)…\n");
|
||||
try {
|
||||
await spawnAsync("pnpm", ["run", "build"], nerveRoot);
|
||||
process.stdout.write(
|
||||
`✅ Build complete — ${join("dist", "workflows", args.name, "index.js")} ready.\n`,
|
||||
);
|
||||
} catch {
|
||||
process.stdout.write(`⚠️ Build failed. Run manually:\n cd ${nerveRoot} && pnpm run build\n`);
|
||||
}
|
||||
|
||||
process.stdout.write("\n💡 Next steps:\n");
|
||||
process.stdout.write(
|
||||
` 1. In ${workflowDir}, run \`npm install\` then \`npm run build\` (bundles to dist/index.js).\n`,
|
||||
);
|
||||
process.stdout.write(" 2. Add to nerve.yaml:\n");
|
||||
process.stdout.write(" 1. Add to nerve.yaml:\n");
|
||||
process.stdout.write(" workflows:\n");
|
||||
process.stdout.write(` ${args.name}:\n`);
|
||||
process.stdout.write(" concurrency: 1\n");
|
||||
process.stdout.write(" overflow: drop\n");
|
||||
process.stdout.write(
|
||||
` 3. Edit ${join(workflowDir, "roles", "main", "index.ts")} (and optional prompt.md).\n`,
|
||||
` 2. Edit ${join(workflowDir, "roles", "main", "index.ts")} (and optional prompt.md).\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
` 4. Adjust moderator routing in ${join(workflowDir, "index.ts")} if you add roles.\n`,
|
||||
` 3. Adjust moderator routing in ${join(workflowDir, "index.ts")} if you add roles.\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
` 4. After edits, run \`pnpm run build\` from the workspace root (${nerveRoot}); output is dist/workflows/<name>/index.js.\n`,
|
||||
);
|
||||
process.stdout.write(" 5. Run `nerve start` to launch the daemon.\n");
|
||||
},
|
||||
@@ -309,26 +279,23 @@ const createSenseCommand = defineCommand({
|
||||
|
||||
mkdirSync(join(senseDir, "src"), { recursive: true });
|
||||
mkdirSync(join(senseDir, "migrations"), { recursive: true });
|
||||
writeFile(join(senseDir, "package.json"), buildSensePackageJson(args.name));
|
||||
writeFile(join(senseDir, "src", "index.ts"), buildSenseIndexTs(args.name));
|
||||
writeFile(join(senseDir, "src", "schema.ts"), buildSenseSchemaTs(args.name));
|
||||
writeFile(join(senseDir, "migrations", "0001_init.sql"), buildSenseMigrationSql(args.name));
|
||||
|
||||
process.stdout.write("✅ Sense scaffolded:\n");
|
||||
process.stdout.write(` ${join(senseDir, "package.json")}\n`);
|
||||
process.stdout.write(` ${join(senseDir, "src", "index.ts")}\n`);
|
||||
process.stdout.write(` ${join(senseDir, "src", "schema.ts")}\n`);
|
||||
process.stdout.write(` ${join(senseDir, "migrations", "0001_init.sql")}\n`);
|
||||
|
||||
process.stdout.write("\nInstalling sense dependencies and building…\n");
|
||||
process.stdout.write("\nBuilding workspace (senses + workflows)…\n");
|
||||
try {
|
||||
await spawnAsync("pnpm", ["install", "--no-cache", "--ignore-workspace"], senseDir);
|
||||
await spawnAsync("pnpm", ["run", "build"], senseDir);
|
||||
process.stdout.write("✅ Build complete — index.js ready.\n");
|
||||
} catch {
|
||||
await spawnAsync("pnpm", ["run", "build"], nerveRoot);
|
||||
process.stdout.write(
|
||||
`⚠️ Build failed. Run manually:\n cd ${senseDir} && pnpm install --no-cache --ignore-workspace && pnpm run build\n`,
|
||||
`✅ Build complete — ${join("dist", "senses", args.name, "index.js")} ready.\n`,
|
||||
);
|
||||
} catch {
|
||||
process.stdout.write(`⚠️ Build failed. Run manually:\n cd ${nerveRoot} && pnpm run build\n`);
|
||||
}
|
||||
|
||||
process.stdout.write("\n💡 Next steps:\n");
|
||||
@@ -341,7 +308,9 @@ const createSenseCommand = defineCommand({
|
||||
process.stdout.write(
|
||||
` 2. Edit ${join(senseDir, "src", "index.ts")} to implement ${args.name}.\n`,
|
||||
);
|
||||
process.stdout.write(` 3. Re-run \`pnpm run build\` in ${senseDir} after edits.\n`);
|
||||
process.stdout.write(
|
||||
` 3. Re-run \`pnpm run build\` from the workspace root (${nerveRoot}) after edits.\n`,
|
||||
);
|
||||
process.stdout.write(" 4. Run `nerve start` to launch the daemon.\n");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -17,11 +17,6 @@ senses:
|
||||
interval: 10s
|
||||
`;
|
||||
|
||||
const PNPM_WORKSPACE_YAML = `packages:
|
||||
- 'workflows/*'
|
||||
- 'senses/*'
|
||||
`;
|
||||
|
||||
const BIOME_JSON = `{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
|
||||
"formatter": {
|
||||
@@ -54,17 +49,20 @@ const PACKAGE_JSON = `${JSON.stringify(
|
||||
private: true,
|
||||
type: "module",
|
||||
scripts: {
|
||||
build: "pnpm -r build",
|
||||
build: "node scripts/build.mjs",
|
||||
},
|
||||
dependencies: {
|
||||
"@uncaged/nerve-core": "latest",
|
||||
"@uncaged/nerve-daemon": "latest",
|
||||
"@uncaged/nerve-skills": "latest",
|
||||
"drizzle-orm": "latest",
|
||||
zod: "^4.3.6",
|
||||
},
|
||||
devDependencies: {
|
||||
"@biomejs/biome": "latest",
|
||||
"@types/node": "^22.0.0",
|
||||
"drizzle-kit": "latest",
|
||||
esbuild: "^0.27.0",
|
||||
typescript: "^5.7.0",
|
||||
},
|
||||
pnpm: {
|
||||
onlyBuiltDependencies: ["esbuild"],
|
||||
@@ -74,6 +72,54 @@ const PACKAGE_JSON = `${JSON.stringify(
|
||||
2,
|
||||
)}\n`;
|
||||
|
||||
const BUILD_MJS = `import * as esbuild from "esbuild";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const dist = path.join(root, "dist");
|
||||
|
||||
const opts = {
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
packages: "external",
|
||||
};
|
||||
|
||||
function listDirs(dir) {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((name) => !name.startsWith(".") && !name.startsWith("_"))
|
||||
.map((name) => ({ name, full: path.join(dir, name) }))
|
||||
.filter(({ full }) => fs.statSync(full).isDirectory());
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Clean dist/
|
||||
fs.rmSync(dist, { recursive: true, force: true });
|
||||
|
||||
for (const { name, full } of listDirs(path.join(root, "senses"))) {
|
||||
const entry = path.join(full, "src", "index.ts");
|
||||
if (!fs.existsSync(entry)) continue;
|
||||
const outfile = path.join(dist, "senses", name, "index.js");
|
||||
fs.mkdirSync(path.dirname(outfile), { recursive: true });
|
||||
await esbuild.build({ ...opts, entryPoints: [entry], outfile });
|
||||
}
|
||||
|
||||
for (const { name, full } of listDirs(path.join(root, "workflows"))) {
|
||||
const entry = path.join(full, "index.ts");
|
||||
if (!fs.existsSync(entry)) continue;
|
||||
const outfile = path.join(dist, "workflows", name, "index.js");
|
||||
fs.mkdirSync(path.dirname(outfile), { recursive: true });
|
||||
await esbuild.build({ ...opts, entryPoints: [entry], outfile });
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
`;
|
||||
|
||||
const GITIGNORE = `data/
|
||||
logs/
|
||||
nerve.pid
|
||||
@@ -83,31 +129,26 @@ knowledge.db
|
||||
|
||||
const NERVE_SKILLS_MDC = `---
|
||||
description: >-
|
||||
Nerve skills package — where bundled Agent Skills live in this workspace and how to use them
|
||||
Where Agent Skills live in this Nerve workspace and how to use them with Cursor
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Nerve skills (\`@uncaged/nerve-skills\`)
|
||||
# Nerve Agent Skills
|
||||
|
||||
This workspace lists **@uncaged/nerve-skills** in \`package.json\`. It ships **Agent Skills** (one directory per skill, each with a \`SKILL.md\`) for Nerve development and related tasks.
|
||||
**Agent Skills** are directories that contain a \`SKILL.md\` (with YAML frontmatter). Cursor loads them from **Project Skills** paths (for example \`.cursor/skills/\` or your global skills directory).
|
||||
|
||||
## After install
|
||||
## Getting Nerve-oriented skills
|
||||
|
||||
Run your package manager in this workspace (e.g. \`pnpm install\`, \`npm install\` — whatever \`nerve init\` used). Then skills are on disk at:
|
||||
There is no separate npm package for skills in the default workspace. To align with Nerve CLI, daemon, and monorepo conventions:
|
||||
|
||||
- \`node_modules/@uncaged/nerve-skills/<skill-id>/SKILL.md\`
|
||||
|
||||
Example (current catalog):
|
||||
|
||||
- **nerve-dev** — Nerve architecture, CLI, sense/workflow patterns, \`nerve.yaml\`, and conventions: read \`node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`.
|
||||
1. Copy or symlink skill folders from the **Nerve** repository (e.g. \`packages/skills/*/\`) into \`.cursor/skills/\`, **or**
|
||||
2. Follow project documentation and \`CLAUDE.md\` / \`.cursor/rules/\` in this repo.
|
||||
|
||||
## How to use in an agent
|
||||
|
||||
1. For tasks that match a skill’s **description** (in the \`SKILL.md\` frontmatter), open that \`SKILL.md\` and follow its structure and checklists.
|
||||
2. Prefer the skill as the **source of truth** for Nerve-specific conventions over generic assumptions.
|
||||
3. If the catalog grows, new skills appear as new sibling directories under \`node_modules/@uncaged/nerve-skills/\`.
|
||||
|
||||
Do not commit \`node_modules\`; the dependency is the supported way to get and update skills to match \`@uncaged/nerve-skills\` on npm.
|
||||
1. When a task matches a skill’s **description** (in \`SKILL.md\` frontmatter), open that file and follow its steps.
|
||||
2. Prefer those conventions for sense/workflow layout, \`nerve.yaml\`, and tooling over generic guesses.
|
||||
3. Keep skills versioned with your dotfiles or project; update them when you upgrade Nerve.
|
||||
`;
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -124,6 +165,8 @@ export const cpuUsage = sqliteTable("cpu_usage", {
|
||||
|
||||
const CPU_INDEX_TS = `import { cpus } from "node:os";
|
||||
|
||||
export { cpuUsage as table } from "./schema.js";
|
||||
|
||||
type SenseResult = {
|
||||
signal: { model: string; loadPercent: number; ts: number };
|
||||
workflow: null;
|
||||
@@ -154,24 +197,6 @@ export async function compute(): Promise<SenseResult> {
|
||||
}
|
||||
`;
|
||||
|
||||
const CPU_SENSE_PACKAGE_JSON = `${JSON.stringify(
|
||||
{
|
||||
name: "nerve-sense-cpu-usage",
|
||||
private: true,
|
||||
type: "module",
|
||||
scripts: {
|
||||
build:
|
||||
"esbuild src/index.ts --bundle --platform=node --format=esm --outdir=. --out-extension:.js=.js --packages=external",
|
||||
},
|
||||
devDependencies: {
|
||||
esbuild: "^0.27.0",
|
||||
"drizzle-orm": "*",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
|
||||
const CPU_MIGRATION_SQL = `CREATE TABLE IF NOT EXISTS cpu_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
@@ -334,10 +359,9 @@ async function runInitWorkspace(force: boolean, skipInstall = false): Promise<vo
|
||||
|
||||
writeFile(join(nerveRoot, "nerve.yaml"), NERVE_YAML);
|
||||
writeFile(join(nerveRoot, "package.json"), PACKAGE_JSON);
|
||||
writeFile(join(nerveRoot, "pnpm-workspace.yaml"), PNPM_WORKSPACE_YAML);
|
||||
writeFile(join(nerveRoot, "scripts", "build.mjs"), BUILD_MJS);
|
||||
writeFile(join(nerveRoot, "biome.json"), BIOME_JSON);
|
||||
writeFile(join(nerveRoot, ".gitignore"), GITIGNORE);
|
||||
writeFile(join(nerveRoot, "senses", "cpu-usage", "package.json"), CPU_SENSE_PACKAGE_JSON);
|
||||
writeFile(join(nerveRoot, "senses", "cpu-usage", "src", "index.ts"), CPU_INDEX_TS);
|
||||
writeFile(join(nerveRoot, "senses", "cpu-usage", "src", "schema.ts"), CPU_SCHEMA_TS);
|
||||
writeFile(
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* IPC event loop.
|
||||
*
|
||||
* Layout assumptions (nerve user config at `~/.uncaged-nerve/`):
|
||||
* senses/<name>/index.js ← compiled compute
|
||||
* dist/senses/<name>/index.js ← bundled compute (esbuild)
|
||||
* senses/<name>/migrations/ ← SQL migration files
|
||||
* data/senses/<name>.db ← SQLite data file
|
||||
* nerve.yaml ← config
|
||||
@@ -19,7 +19,7 @@ import { readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { parseNerveConfig } from "@uncaged/nerve-core";
|
||||
import type { NerveConfig, WorkflowTrigger } from "@uncaged/nerve-core";
|
||||
import type { NerveConfig } from "@uncaged/nerve-core";
|
||||
|
||||
import type { WorkerToParentMessage } from "./ipc.js";
|
||||
import { parseParentMessage } from "./ipc.js";
|
||||
@@ -49,10 +49,6 @@ function sendError(sense: string, error: string): void {
|
||||
send({ type: "error", sense, error });
|
||||
}
|
||||
|
||||
function sendWorkflowTrigger(sense: string, workflow: WorkflowTrigger): void {
|
||||
send({ type: "sense-workflow-trigger", sense, workflow });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialisation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -154,10 +150,8 @@ async function runCompute(
|
||||
}
|
||||
clearGracePeriodTimer(senseName);
|
||||
if (result.value != null) {
|
||||
sendSignal(senseName, result.value.signal);
|
||||
if (result.value.workflow !== null) {
|
||||
sendWorkflowTrigger(senseName, result.value.workflow);
|
||||
}
|
||||
// Single IPC message: kernel uses routeSenseComputeOutput(payload) for signal + optional workflow.
|
||||
sendSignal(senseName, result.value);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const errMsg = e instanceof Error ? e.message : String(e);
|
||||
|
||||
Reference in New Issue
Block a user