refactor(cli): add nerve create command, remove init workflow

- Add top-level `nerve create` with `workflow` and `sense` subcommands
- Move workflow scaffold from `init workflow` to `nerve create workflow`
- Add `nerve create sense <name>` to scaffold sense boilerplate
- Keep `nerve init` for workspace initialization only
- Add tests for create workflow, create sense, and e2e

Closes #188
This commit is contained in:
2026-04-27 18:16:19 +08:00
parent c6c3e0142d
commit d13fbe08db
12 changed files with 455 additions and 108 deletions
+1 -3
View File
@@ -10,9 +10,7 @@
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"files": ["dist"],
"publishConfig": {
"access": "public"
},
@@ -0,0 +1,54 @@
/**
* Tests for nerve create sense template helpers.
*/
import { describe, expect, it } from "vitest";
import {
buildSenseIndexJs,
buildSenseMigrationSql,
buildSenseSchemaTs,
validateSenseName,
} from "../commands/create.js";
describe("validateSenseName", () => {
it("accepts valid ids", () => {
expect(validateSenseName("a")).toBe(null);
expect(validateSenseName("my-sense")).toBe(null);
expect(validateSenseName("cpu-usage")).toBe(null);
});
it("rejects invalid ids", () => {
expect(validateSenseName("")).not.toBe(null);
expect(validateSenseName("My-Sense")).not.toBe(null);
expect(validateSenseName("-bad")).not.toBe(null);
});
});
describe("buildSenseSchemaTs", () => {
it("maps kebab-case id to snake table and camel export", () => {
const src = buildSenseSchemaTs("my-sense");
expect(src).toContain('sqliteTable("my_sense"');
expect(src).toContain("export const mySense = ");
});
it("handles single-segment id", () => {
const src = buildSenseSchemaTs("metrics");
expect(src).toContain('sqliteTable("metrics"');
expect(src).toContain("export const metrics = ");
});
});
describe("buildSenseMigrationSql", () => {
it("uses snake_case table name", () => {
expect(buildSenseMigrationSql("disk-io")).toContain("CREATE TABLE IF NOT EXISTS disk_io");
});
});
describe("buildSenseIndexJs", () => {
it("embeds sense id in stub", () => {
const js = buildSenseIndexJs("my-sense");
expect(js).toContain("my-sense");
expect(js).toContain("export async function compute");
});
});
@@ -1,15 +1,15 @@
/**
* Tests for nerve init workflow scaffold logic.
* Tests for nerve create workflow scaffold logic.
*
* We test the file-generation path by isolating the template rendering,
* not by invoking the full citty command (which calls process.exit).
*/
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildWorkflowTemplate } from "../commands/init.js";
import { buildWorkflowTemplate } from "../commands/create.js";
let tmpDir: string;
@@ -68,7 +68,6 @@ describe("buildWorkflowTemplate", () => {
describe("workflow scaffold file writing (simulated)", () => {
it("writes the template to disk correctly", () => {
const { mkdirSync, writeFileSync } = require("node:fs");
const workflowDir = join(tmpDir, "workflows", "my-task");
mkdirSync(workflowDir, { recursive: true });
const content = buildWorkflowTemplate("my-task");
@@ -0,0 +1,169 @@
/**
* E2E-style tests for `nerve create workflow` and `nerve create sense`.
*/
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { defineCommand, runCommand } from "citty";
import { afterEach, describe, expect, it } from "vitest";
import { createCommand } from "../commands/create.js";
import { initCommand } from "../commands/init.js";
const testRootCommand = defineCommand({
meta: { name: "nerve", description: "e2e-create" },
subCommands: {
init: initCommand,
create: createCommand,
},
});
type CliRunResult = {
stdout: string;
stderr: string;
exitCode: number;
};
class ProcessExitError extends Error {
readonly code: number;
constructor(code: number) {
super(`process.exit(${String(code)})`);
this.name = "ProcessExitError";
this.code = code;
}
}
function patchWriteStream(stream: NodeJS.WriteStream, sink: string[]): () => void {
const orig = stream.write.bind(stream) as (
chunk: string | Uint8Array,
encodingOrCb?: BufferEncoding | ((err: Error | null | undefined) => void),
cb?: (err: Error | null | undefined) => void,
) => boolean;
stream.write = ((
chunk: string | Uint8Array,
encodingOrCb?: BufferEncoding | ((err: Error | null | undefined) => void),
cb?: (err: Error | null | undefined) => void,
) => {
if (typeof chunk === "string") {
sink.push(chunk);
} else {
sink.push(Buffer.from(chunk).toString("utf8"));
}
if (typeof encodingOrCb === "function") {
encodingOrCb(null);
return true;
}
if (cb !== undefined) {
cb(null);
}
return true;
}) as typeof stream.write;
return () => {
stream.write = orig as typeof stream.write;
};
}
async function runTestCli(fakeHome: string, args: string[]): Promise<CliRunResult> {
const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];
const restoreOut = patchWriteStream(process.stdout, stdoutChunks);
const restoreErr = patchWriteStream(process.stderr, stderrChunks);
const prevHome = process.env.HOME;
process.env.HOME = fakeHome;
let exitCode = 0;
const origExit = process.exit;
process.exit = ((code?: number) => {
exitCode = typeof code === "number" ? code : 0;
throw new ProcessExitError(exitCode);
}) as typeof process.exit;
try {
await runCommand(testRootCommand, { rawArgs: args });
} catch (e) {
if (e instanceof ProcessExitError) {
exitCode = e.code;
} else {
exitCode = 1;
stderrChunks.push(e instanceof Error ? e.message : String(e));
}
} finally {
process.exit = origExit;
if (prevHome === undefined) {
// biome-ignore lint/performance/noDelete: semantically correct for env cleanup
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
restoreOut();
restoreErr();
}
return {
stdout: stdoutChunks.join(""),
stderr: stderrChunks.join(""),
exitCode,
};
}
describe("e2e create", () => {
let fakeHome: string | null = null;
afterEach(() => {
if (fakeHome !== null) {
rmSync(fakeHome, { recursive: true, force: true });
fakeHome = null;
}
});
it("create workflow scaffolds index.ts", { timeout: 10_000 }, async () => {
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
const nerveRoot = join(fakeHome, ".uncaged-nerve");
await runTestCli(fakeHome, ["init", "--force", "--skip-install"]);
const wf = await runTestCli(fakeHome, ["create", "workflow", "e2e-flow"]);
expect(wf.exitCode).toBe(0);
expect(wf.stdout).toContain("✅");
const indexPath = join(nerveRoot, "workflows", "e2e-flow", "index.ts");
expect(existsSync(indexPath)).toBe(true);
expect(readFileSync(indexPath, "utf8")).toContain("e2e-flow started");
});
it("create sense scaffolds index.js, schema.ts, and migration", { timeout: 10_000 }, async () => {
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
const nerveRoot = join(fakeHome, ".uncaged-nerve");
await runTestCli(fakeHome, ["init", "--force", "--skip-install"]);
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, "index.js"))).toBe(true);
expect(existsSync(join(base, "schema.ts"))).toBe(true);
expect(existsSync(join(base, "migrations", "0001_init.sql"))).toBe(true);
});
it(
"create workflow exits 1 when directory exists without --force",
{ timeout: 10_000 },
async () => {
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
const nerveRoot = join(fakeHome, ".uncaged-nerve");
mkdirSync(join(nerveRoot, "workflows", "dup-wf"), { recursive: true });
writeFileSync(join(nerveRoot, "workflows", "dup-wf", "index.ts"), "// x", "utf8");
const first = await runTestCli(fakeHome, ["create", "workflow", "dup-wf"]);
expect(first.exitCode).toBe(1);
expect(first.stderr).toContain("already exists");
},
);
});
+3 -3
View File
@@ -8,7 +8,7 @@
- 🔲 init in empty dir without `--force` — succeeds
- 🔲 `--from <git-url>` clones and sets up workspace
## init workflow
## create workflow / create sense
- 🔲 creates workflow scaffold under workflows/
- 🔲 generated workflow has valid structure
- 🔲 `nerve create workflow <name>` scaffolds under workflows/
- 🔲 `nerve create sense <name>` scaffolds under senses/
+2
View File
@@ -3,6 +3,7 @@ import "@uncaged/nerve-daemon/experimental-warning-suppression.js";
import { defineCommand, runMain } from "citty";
import { consumeGlobalDaemonCliFlags } from "./cli-global.js";
import { createCommand } from "./commands/create.js";
import { daemonCommand } from "./commands/daemon.js";
import { devCommand } from "./commands/dev.js";
import { initCommand } from "./commands/init.js";
@@ -41,6 +42,7 @@ const main = defineCommand({
},
subCommands: {
init: initCommand,
create: createCommand,
daemon: daemonCommand,
dev: devCommand,
validate: validateCommand,
+218
View File
@@ -0,0 +1,218 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { defineCommand } from "citty";
import { getNerveRoot } from "../workspace.js";
export const WORKFLOW_NAME_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
export function validateWorkflowName(name: string): string | null {
if (name.length === 0) return "Workflow name must not be empty.";
if (name.length > 64) return "Workflow name must be 64 characters or fewer.";
if (!WORKFLOW_NAME_RE.test(name))
return "Workflow name must contain only lowercase letters, digits, and hyphens, and must not start or end with a hyphen.";
return null;
}
export function validateSenseName(name: string): string | null {
if (name.length === 0) return "Sense name must not be empty.";
if (name.length > 64) return "Sense name must be 64 characters or fewer.";
if (!WORKFLOW_NAME_RE.test(name))
return "Sense name must contain only lowercase letters, digits, and hyphens, and must not start or end with a hyphen.";
return null;
}
export function buildWorkflowTemplate(name: string): string {
return `import type { WorkflowDefinition } from "@uncaged/nerve-daemon";
const workflow: WorkflowDefinition = {
roles: {
main: {
async execute(prompt, ctx) {
ctx.log("${name} started");
// TODO: implement your role logic here
return { type: "done" };
},
},
},
moderate(thread, event) {
if (event.type === "thread_start") {
return { role: "main", prompt: {} };
}
return null; // workflow complete
},
};
export default workflow;
`;
}
function senseIdToSqlTableName(id: string): string {
return id.replaceAll("-", "_");
}
function senseIdToSchemaExportName(id: string): string {
const parts = id.split("-");
return parts
.map((part, index) =>
index === 0 ? part : part.length === 0 ? "" : part.charAt(0).toUpperCase() + part.slice(1),
)
.join("");
}
export function buildSenseSchemaTs(senseId: string): string {
const table = senseIdToSqlTableName(senseId);
const exportName = senseIdToSchemaExportName(senseId);
return `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const ${exportName} = sqliteTable("${table}", {
id: integer("id").primaryKey({ autoIncrement: true }),
ts: integer("ts").notNull(),
label: text("label").notNull(),
});
`;
}
export function buildSenseIndexJs(senseId: string): string {
return `/**
* ${senseId} — replace this stub with your sampling logic.
* Returns non-null to emit a signal, null to stay silent.
*/
export async function compute(db, peers, options) {
return {
label: "${senseId}",
ts: Date.now(),
};
}
`;
}
export function buildSenseMigrationSql(senseId: string): string {
const table = senseIdToSqlTableName(senseId);
return `CREATE TABLE IF NOT EXISTS ${table} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
label TEXT NOT NULL
);
`;
}
function writeFile(filePath: string, content: string): void {
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, content, "utf8");
}
const createWorkflowCommand = defineCommand({
meta: {
name: "workflow",
description: "Scaffold a new workflow at ~/.uncaged-nerve/workflows/<name>/",
},
args: {
name: {
type: "positional",
description: "Workflow name (must match the key in nerve.yaml workflows section)",
},
force: {
type: "boolean",
description: "Overwrite if the workflow directory already exists",
default: false,
},
},
async run({ args }) {
const nerveRoot = getNerveRoot();
const workflowDir = join(nerveRoot, "workflows", args.name);
const nameError = validateWorkflowName(args.name);
if (nameError !== null) {
process.stderr.write(`❌ Invalid workflow name: ${nameError}\n`);
process.exit(1);
}
if (existsSync(workflowDir) && !args.force) {
process.stderr.write(
`⚠️ Workflow "${args.name}" already exists at ${workflowDir}. Use --force to overwrite.\n`,
);
process.exit(1);
}
mkdirSync(workflowDir, { recursive: true });
writeFile(join(workflowDir, "index.ts"), buildWorkflowTemplate(args.name));
process.stdout.write(`✅ Workflow scaffolded: ${workflowDir}/index.ts\n`);
process.stdout.write("\n💡 Next steps:\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(` 2. Edit ${workflowDir}/index.ts to implement your roles.\n`);
process.stdout.write(" 3. Run `nerve start` to launch the daemon.\n");
},
});
const createSenseCommand = defineCommand({
meta: {
name: "sense",
description: "Scaffold a new sense at ~/.uncaged-nerve/senses/<name>/",
},
args: {
name: {
type: "positional",
description: "Sense id (must match the key in nerve.yaml senses section)",
},
force: {
type: "boolean",
description: "Overwrite if the sense directory already exists",
default: false,
},
},
async run({ args }) {
const nerveRoot = getNerveRoot();
const senseDir = join(nerveRoot, "senses", args.name);
const nameError = validateSenseName(args.name);
if (nameError !== null) {
process.stderr.write(`❌ Invalid sense name: ${nameError}\n`);
process.exit(1);
}
if (existsSync(senseDir) && !args.force) {
process.stderr.write(
`⚠️ Sense "${args.name}" already exists at ${senseDir}. Use --force to overwrite.\n`,
);
process.exit(1);
}
mkdirSync(join(senseDir, "migrations"), { recursive: true });
writeFile(join(senseDir, "index.js"), buildSenseIndexJs(args.name));
writeFile(join(senseDir, "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, "index.js")}\n`);
process.stdout.write(` ${join(senseDir, "schema.ts")}\n`);
process.stdout.write(` ${join(senseDir, "migrations", "0001_init.sql")}\n`);
process.stdout.write("\n💡 Next steps:\n");
process.stdout.write(" 1. Add to nerve.yaml under senses:\n");
process.stdout.write(` ${args.name}:\n`);
process.stdout.write(" group: default\n");
process.stdout.write(" throttle: null\n");
process.stdout.write(" timeout: 10s\n");
process.stdout.write(" grace_period: null\n");
process.stdout.write(` 2. Edit the scaffolded files to implement ${args.name}.\n`);
process.stdout.write(" 3. Run `nerve start` to launch the daemon.\n");
},
});
export const createCommand = defineCommand({
meta: {
name: "create",
description: "Scaffold a new workflow or sense in the Nerve workspace",
},
subCommands: {
workflow: createWorkflowCommand,
sense: createSenseCommand,
},
});
+1 -86
View File
@@ -145,90 +145,6 @@ async function detectPackageManager(): Promise<{ cmd: string; installArgs: strin
return { cmd: "npm", installArgs: ["install"] };
}
export const WORKFLOW_NAME_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
export function validateWorkflowName(name: string): string | null {
if (name.length === 0) return "Workflow name must not be empty.";
if (name.length > 64) return "Workflow name must be 64 characters or fewer.";
if (!WORKFLOW_NAME_RE.test(name))
return "Workflow name must contain only lowercase letters, digits, and hyphens, and must not start or end with a hyphen.";
return null;
}
export function buildWorkflowTemplate(name: string): string {
return `import type { WorkflowDefinition } from "@uncaged/nerve-daemon";
const workflow: WorkflowDefinition = {
roles: {
main: {
async execute(prompt, ctx) {
ctx.log("${name} started");
// TODO: implement your role logic here
return { type: "done" };
},
},
},
moderate(thread, event) {
if (event.type === "thread_start") {
return { role: "main", prompt: {} };
}
return null; // workflow complete
},
};
export default workflow;
`;
}
const initWorkflowCommand = defineCommand({
meta: {
name: "workflow",
description: "Scaffold a new workflow template in ~/.uncaged-nerve/workflows/<name>/",
},
args: {
name: {
type: "positional",
description: "Workflow name (must match the key in nerve.yaml workflows section)",
},
force: {
type: "boolean",
description: "Overwrite if the workflow directory already exists",
default: false,
},
},
async run({ args }) {
const nerveRoot = getNerveRoot();
const workflowDir = join(nerveRoot, "workflows", args.name);
const nameError = validateWorkflowName(args.name);
if (nameError !== null) {
process.stderr.write(`❌ Invalid workflow name: ${nameError}\n`);
process.exit(1);
}
if (existsSync(workflowDir) && !args.force) {
process.stderr.write(
`⚠️ Workflow "${args.name}" already exists at ${workflowDir}. Use --force to overwrite.\n`,
);
process.exit(1);
}
mkdirSync(workflowDir, { recursive: true });
writeFile(join(workflowDir, "index.ts"), buildWorkflowTemplate(args.name));
process.stdout.write(`✅ Workflow scaffolded: ${workflowDir}/index.ts\n`);
process.stdout.write("\n💡 Next steps:\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(` 2. Edit ${workflowDir}/index.ts to implement your roles.\n`);
process.stdout.write(" 3. Run `nerve start` to launch the daemon.\n");
},
});
const initWorkspaceCommand = defineCommand({
meta: {
name: "workspace",
@@ -400,7 +316,7 @@ export const initCommand = defineCommand({
meta: {
name: "init",
description:
"Initialize workspace (nerve init), clone from git (nerve init --from <url>), or scaffold templates (nerve init workflow <name>)",
"Initialize workspace (nerve init), clone from git (nerve init --from <url>), or reinit workspace (nerve init workspace)",
},
args: {
force: {
@@ -420,7 +336,6 @@ export const initCommand = defineCommand({
},
},
subCommands: {
workflow: initWorkflowCommand,
workspace: initWorkspaceCommand,
},
async run({ args }) {
+1 -3
View File
@@ -9,9 +9,7 @@
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"files": ["dist"],
"publishConfig": {
"access": "public"
},
+1 -3
View File
@@ -15,9 +15,7 @@
},
"./package.json": "./package.json"
},
"files": [
"dist"
],
"files": ["dist"],
"publishConfig": {
"access": "public"
},
+1 -3
View File
@@ -4,9 +4,7 @@
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"files": ["dist"],
"publishConfig": {
"access": "public"
},
+1 -3
View File
@@ -4,9 +4,7 @@
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"files": ["dist"],
"publishConfig": {
"access": "public"
},