From 2df8accf2f1f09a38ad64784b9691a81aea93f5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Thu, 7 May 2026 21:18:58 +0800 Subject: [PATCH 1/3] feat(cli): add init workspace command (#36 Phase 1) - Add cmd-init.ts with cmdInitWorkspace and stub cmdInitTemplate - Wire init subcommands into cli-dispatch.ts - Generate monorepo skeleton: package.json (bun workspace), biome.json, tsconfig.json, AGENTS.md placeholder, README.md, templates/, workflows/ - Error on existing directory - Add init-workspace.test.ts (all passing) Testing: #46 --- .../__tests__/init-workspace.test.ts | 117 +++++++++++++ packages/cli-workflow/src/cli-dispatch.ts | 85 +++++++--- packages/cli-workflow/src/cli.ts | 0 packages/cli-workflow/src/cmd-init.ts | 160 ++++++++++++++++++ 4 files changed, 337 insertions(+), 25 deletions(-) create mode 100644 packages/cli-workflow/__tests__/init-workspace.test.ts mode change 100644 => 100755 packages/cli-workflow/src/cli.ts create mode 100644 packages/cli-workflow/src/cmd-init.ts diff --git a/packages/cli-workflow/__tests__/init-workspace.test.ts b/packages/cli-workflow/__tests__/init-workspace.test.ts new file mode 100644 index 0000000..1589796 --- /dev/null +++ b/packages/cli-workflow/__tests__/init-workspace.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { formatCliUsage, runCli } from "../src/cli-dispatch.js"; +import { cmdInitTemplate, cmdInitWorkspace } from "../src/cmd-init.js"; +import { pathExists } from "../src/fs-utils.js"; + +describe("init workspace", () => { + let parent: string; + + beforeEach(async () => { + parent = join(tmpdir(), `wf-init-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(parent, { recursive: true }); + }); + + afterEach(async () => { + await rm(parent, { recursive: true, force: true }); + }); + + test("creates expected files and directories", async () => { + const created = await cmdInitWorkspace(parent, "my-workflows"); + expect(created.ok).toBe(true); + if (!created.ok) { + return; + } + + const root = created.value.rootPath; + expect(await pathExists(join(root, "package.json"))).toBe(true); + expect(await pathExists(join(root, "biome.json"))).toBe(true); + expect(await pathExists(join(root, "tsconfig.json"))).toBe(true); + expect(await pathExists(join(root, "AGENTS.md"))).toBe(true); + expect(await pathExists(join(root, "README.md"))).toBe(true); + expect(await pathExists(join(root, "templates"))).toBe(true); + expect(await pathExists(join(root, "templates", ".gitkeep"))).toBe(true); + expect(await pathExists(join(root, "workflows", "package.json"))).toBe(true); + + const rootPkg = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as { + workspaces: string[]; + }; + expect(rootPkg.workspaces).toEqual(["templates/*", "workflows"]); + + const wfPkg = JSON.parse(await readFile(join(root, "workflows", "package.json"), "utf8")) as { + type: string; + dependencies: Record; + }; + expect(wfPkg.type).toBe("module"); + expect(wfPkg.dependencies["@uncaged/workflow"]).toBeDefined(); + expect(wfPkg.dependencies.zod).toBeDefined(); + + const tsconfig = JSON.parse(await readFile(join(root, "tsconfig.json"), "utf8")) as { + compilerOptions: { strict: boolean; module: string; target: string }; + }; + expect(tsconfig.compilerOptions.strict).toBe(true); + expect(tsconfig.compilerOptions.module).toBe("ESNext"); + expect(tsconfig.compilerOptions.target).toBe("ESNext"); + }); + + test("errors when directory already exists", async () => { + const first = await cmdInitWorkspace(parent, "dup"); + expect(first.ok).toBe(true); + + const second = await cmdInitWorkspace(parent, "dup"); + expect(second.ok).toBe(false); + if (!second.ok) { + expect(second.error).toContain("already exists"); + } + }); + + test("errors on invalid workspace name", async () => { + const slash = await cmdInitWorkspace(parent, "a/b"); + expect(slash.ok).toBe(false); + + const dots = await cmdInitWorkspace(parent, ".."); + expect(dots.ok).toBe(false); + + const empty = await cmdInitWorkspace(parent, ""); + expect(empty.ok).toBe(false); + }); + + test("usage lists init subcommands", () => { + const u = formatCliUsage(); + expect(u).toContain("uncaged-workflow init workspace "); + expect(u).toContain("uncaged-workflow init template "); + }); + + test("init template command is stubbed", () => { + const r = cmdInitTemplate(parent, "x"); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toBe("not implemented yet"); + } + }); + + test("runCli rejects unknown init subcommand", async () => { + const code = await runCli(join(parent, "_storage"), ["init", "bogus", "name"]); + expect(code).toBe(1); + }); + + test.serial("runCli init workspace uses cwd", async () => { + const prev = process.cwd(); + try { + process.chdir(parent); + const code = await runCli(join(parent, "_storage"), ["init", "workspace", "from-cli"]); + expect(code).toBe(0); + expect(await pathExists(join(parent, "from-cli", "workflows", "package.json"))).toBe(true); + } finally { + process.chdir(prev); + } + }); + + test("runCli init template exits with error", async () => { + const code = await runCli(join(parent, "_storage"), ["init", "template", "t"]); + expect(code).toBe(1); + }); +}); diff --git a/packages/cli-workflow/src/cli-dispatch.ts b/packages/cli-workflow/src/cli-dispatch.ts index 044d20f..4151631 100644 --- a/packages/cli-workflow/src/cli-dispatch.ts +++ b/packages/cli-workflow/src/cli-dispatch.ts @@ -4,6 +4,7 @@ import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "./cmd-cas.js"; import { cmdFork, parseForkArgv } from "./cmd-fork.js"; import { cmdGc } from "./cmd-gc.js"; import { cmdHistory } from "./cmd-history.js"; +import { cmdInitTemplate, cmdInitWorkspace } from "./cmd-init.js"; import { cmdKill } from "./cmd-kill.js"; import { cmdList, formatListLines } from "./cmd-list.js"; import { cmdPause } from "./cmd-pause.js"; @@ -17,7 +18,7 @@ import { cmdThreadRemove, cmdThreadShow } from "./cmd-thread.js"; import { cmdThreads } from "./cmd-threads.js"; import { parseRunArgv } from "./run-argv.js"; -function usage(): string { +export function formatCliUsage(): string { return [ "Usage:", " uncaged-workflow add [--types ]", @@ -40,13 +41,46 @@ function usage(): string { " uncaged-workflow cas put ", " uncaged-workflow cas list ", " uncaged-workflow cas rm ", + " uncaged-workflow init workspace ", + " uncaged-workflow init template ", ].join("\n"); } +async function dispatchInit(_storageRoot: string, argv: string[]): Promise { + const sub = argv[0]; + const name = argv[1]; + if (sub === undefined || name === undefined || argv.length > 2) { + printCliError(`${formatCliUsage()}\n\nerror: init requires workspace|template `); + return 1; + } + + if (sub === "workspace") { + const result = await cmdInitWorkspace(process.cwd(), name); + if (!result.ok) { + printCliError(result.error); + return 1; + } + printCliLine(`initialized workflow workspace at ${result.value.rootPath}`); + return 0; + } + + if (sub === "template") { + const result = cmdInitTemplate(process.cwd(), name); + if (!result.ok) { + printCliError(result.error); + return 1; + } + return 0; + } + + printCliError(`${formatCliUsage()}\n\nerror: unknown init subcommand: ${sub}`); + return 1; +} + async function dispatchAdd(storageRoot: string, argv: string[]): Promise { const parsed = parseAddArgv(argv); if (!parsed.ok) { - printCliError(`${usage()}\n\nerror: ${parsed.error}`); + printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`); return 1; } const result = await cmdAdd(storageRoot, parsed.value); @@ -63,7 +97,7 @@ async function dispatchAdd(storageRoot: string, argv: string[]): Promise async function dispatchList(storageRoot: string, argv: string[]): Promise { if (argv.length > 0) { - printCliError(`${usage()}\n\nerror: list takes no arguments`); + printCliError(`${formatCliUsage()}\n\nerror: list takes no arguments`); return 1; } const result = await cmdList(storageRoot); @@ -80,7 +114,7 @@ async function dispatchList(storageRoot: string, argv: string[]): Promise { const name = argv[0]; if (name === undefined || argv.length > 1) { - printCliError(`${usage()}\n\nerror: show requires `); + printCliError(`${formatCliUsage()}\n\nerror: show requires `); return 1; } const result = await cmdShow(storageRoot, name); @@ -95,7 +129,7 @@ async function dispatchShow(storageRoot: string, argv: string[]): Promise { const name = argv[0]; if (name === undefined || argv.length > 1) { - printCliError(`${usage()}\n\nerror: remove requires `); + printCliError(`${formatCliUsage()}\n\nerror: remove requires `); return 1; } const result = await cmdRemove(storageRoot, name); @@ -110,7 +144,7 @@ async function dispatchRemove(storageRoot: string, argv: string[]): Promise { const parsed = parseRunArgv(argv); if (!parsed.ok) { - printCliError(`${usage()}\n\nerror: ${parsed.error}`); + printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`); return 1; } @@ -131,7 +165,7 @@ async function dispatchRun(storageRoot: string, argv: string[]): Promise async function dispatchPs(storageRoot: string, argv: string[]): Promise { if (argv.length > 0) { - printCliError(`${usage()}\n\nerror: ps takes no arguments`); + printCliError(`${formatCliUsage()}\n\nerror: ps takes no arguments`); return 1; } for (const line of await cmdPs(storageRoot)) { @@ -143,7 +177,7 @@ async function dispatchPs(storageRoot: string, argv: string[]): Promise async function dispatchKill(storageRoot: string, argv: string[]): Promise { const threadId = argv[0]; if (threadId === undefined || argv.length > 1) { - printCliError(`${usage()}\n\nerror: kill requires `); + printCliError(`${formatCliUsage()}\n\nerror: kill requires `); return 1; } const result = await cmdKill(storageRoot, threadId); @@ -158,7 +192,7 @@ async function dispatchKill(storageRoot: string, argv: string[]): Promise { const name = argv[0]; if (name === undefined || argv.length > 1) { - printCliError(`${usage()}\n\nerror: history requires `); + printCliError(`${formatCliUsage()}\n\nerror: history requires `); return 1; } const result = await cmdHistory(storageRoot, name); @@ -175,7 +209,7 @@ async function dispatchHistory(storageRoot: string, argv: string[]): Promise { const name = argv[0]; if (name === undefined || argv.length > 2) { - printCliError(`${usage()}\n\nerror: rollback requires [hash]`); + printCliError(`${formatCliUsage()}\n\nerror: rollback requires [hash]`); return 1; } const hashArg = argv[1]; @@ -191,7 +225,7 @@ async function dispatchRollback(storageRoot: string, argv: string[]): Promise { const threadId = argv[0]; if (threadId === undefined || argv.length > 1) { - printCliError(`${usage()}\n\nerror: pause requires `); + printCliError(`${formatCliUsage()}\n\nerror: pause requires `); return 1; } const result = await cmdPause(storageRoot, threadId); @@ -206,7 +240,7 @@ async function dispatchPause(storageRoot: string, argv: string[]): Promise { const threadId = argv[0]; if (threadId === undefined || argv.length > 1) { - printCliError(`${usage()}\n\nerror: resume requires `); + printCliError(`${formatCliUsage()}\n\nerror: resume requires `); return 1; } const result = await cmdResume(storageRoot, threadId); @@ -233,7 +267,7 @@ async function dispatchThreads(storageRoot: string, argv: string[]): Promise { const id = argv[0]; if (id === undefined || argv.length > 1) { - printCliError(`${usage()}\n\nerror: thread requires `); + printCliError(`${formatCliUsage()}\n\nerror: thread requires `); return 1; } const result = await cmdThreadShow(storageRoot, id); @@ -248,7 +282,7 @@ async function dispatchThread(storageRoot: string, argv: string[]): Promise { const id = argv[0]; if (id === undefined || argv.length > 1) { - printCliError(`${usage()}\n\nerror: thread rm requires `); + printCliError(`${formatCliUsage()}\n\nerror: thread rm requires `); return 1; } const result = await cmdThreadRemove(storageRoot, id); @@ -270,7 +304,7 @@ async function dispatchThreadBranch(storageRoot: string, rest: string[]): Promis async function dispatchGc(storageRoot: string, argv: string[]): Promise { if (argv.length > 0) { - printCliError(`${usage()}\n\nerror: gc takes no arguments`); + printCliError(`${formatCliUsage()}\n\nerror: gc takes no arguments`); return 1; } const result = await cmdGc(storageRoot); @@ -288,7 +322,7 @@ async function dispatchGc(storageRoot: string, argv: string[]): Promise async function dispatchFork(storageRoot: string, argv: string[]): Promise { const parsed = parseForkArgv(argv); if (!parsed.ok) { - printCliError(`${usage()}\n\nerror: ${parsed.error}`); + printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`); return 1; } const result = await cmdFork(storageRoot, parsed.value.threadId, parsed.value.fromRole); @@ -304,7 +338,7 @@ async function dispatchCasGet(storageRoot: string, rest: string[]): Promise 2) { - printCliError(`${usage()}\n\nerror: cas get requires `); + printCliError(`${formatCliUsage()}\n\nerror: cas get requires `); return 1; } const result = await cmdCasGet(storageRoot, threadId, hash); @@ -320,7 +354,7 @@ async function dispatchCasPut(storageRoot: string, rest: string[]): Promise 2) { - printCliError(`${usage()}\n\nerror: cas put requires `); + printCliError(`${formatCliUsage()}\n\nerror: cas put requires `); return 1; } const result = await cmdCasPut(storageRoot, threadId, content); @@ -335,7 +369,7 @@ async function dispatchCasPut(storageRoot: string, rest: string[]): Promise { const threadId = rest[0]; if (threadId === undefined || rest.length > 1) { - printCliError(`${usage()}\n\nerror: cas list requires `); + printCliError(`${formatCliUsage()}\n\nerror: cas list requires `); return 1; } const result = await cmdCasList(storageRoot, threadId); @@ -353,7 +387,7 @@ async function dispatchCasRm(storageRoot: string, rest: string[]): Promise 2) { - printCliError(`${usage()}\n\nerror: cas rm requires `); + printCliError(`${formatCliUsage()}\n\nerror: cas rm requires `); return 1; } const result = await cmdCasRm(storageRoot, threadId, hash); @@ -378,12 +412,12 @@ const CAS_SUBCOMMAND_TABLE: Record< async function dispatchCas(storageRoot: string, argv: string[]): Promise { const sub = argv[0]; if (sub === undefined) { - printCliError(`${usage()}\n\nerror: unknown cas subcommand: (none)`); + printCliError(`${formatCliUsage()}\n\nerror: unknown cas subcommand: (none)`); return 1; } const handler = CAS_SUBCOMMAND_TABLE[sub]; if (handler === undefined) { - printCliError(`${usage()}\n\nerror: unknown cas subcommand: ${sub}`); + printCliError(`${formatCliUsage()}\n\nerror: unknown cas subcommand: ${sub}`); return 1; } return handler(storageRoot, argv.slice(1)); @@ -393,6 +427,7 @@ type DispatchFn = (storageRoot: string, argv: string[]) => Promise; const COMMAND_TABLE: Record = { add: dispatchAdd, + init: dispatchInit, list: dispatchList, show: dispatchShow, remove: dispatchRemove, @@ -412,18 +447,18 @@ const COMMAND_TABLE: Record = { export async function runCli(storageRoot: string, argv: string[]): Promise { if (argv.length === 0) { - printCliError(usage()); + printCliError(formatCliUsage()); return 1; } const command = argv[0]; if (command === undefined) { - printCliError(usage()); + printCliError(formatCliUsage()); return 1; } const rest = argv.slice(1); const dispatch = COMMAND_TABLE[command]; if (dispatch === undefined) { - printCliError(`${usage()}\n\nerror: unknown command ${command}`); + printCliError(`${formatCliUsage()}\n\nerror: unknown command ${command}`); return 1; } return dispatch(storageRoot, rest); diff --git a/packages/cli-workflow/src/cli.ts b/packages/cli-workflow/src/cli.ts old mode 100644 new mode 100755 diff --git a/packages/cli-workflow/src/cmd-init.ts b/packages/cli-workflow/src/cmd-init.ts new file mode 100644 index 0000000..21b1f5b --- /dev/null +++ b/packages/cli-workflow/src/cmd-init.ts @@ -0,0 +1,160 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { err, ok, type Result } from "@uncaged/workflow"; + +import { pathExists } from "./fs-utils.js"; + +export type CmdInitWorkspaceSuccess = { + rootPath: string; +}; + +function validateWorkspaceSegment(name: string): Result { + if (name.length === 0) { + return err("workspace name must not be empty"); + } + if (name === "." || name === "..") { + return err("invalid workspace name"); + } + if (name.includes("/") || name.includes("\\")) { + return err("workspace name must not contain path separators"); + } + return ok(undefined); +} + +function rootPackageJson(workspaceName: string): string { + return `${JSON.stringify( + { + name: workspaceName, + private: true, + type: "module", + workspaces: ["templates/*", "workflows"], + }, + null, + 2, + )}\n`; +} + +function workflowsPackageJson(): string { + return `${JSON.stringify( + { + name: "workflows", + version: "0.0.0", + private: true, + type: "module", + dependencies: { + "@uncaged/workflow": "^0.1.0", + zod: "^4.0.0", + }, + }, + null, + 2, + )}\n`; +} + +function biomeJson(): string { + return `${JSON.stringify( + { + $schema: "https://biomejs.dev/schemas/2.4.14/schema.json", + files: { + includes: ["**", "!**/node_modules", "!**/dist"], + }, + formatter: { + indentWidth: 2, + }, + linter: { + enabled: true, + rules: { + recommended: true, + }, + }, + }, + null, + 2, + )}\n`; +} + +function tsconfigJson(): string { + return `${JSON.stringify( + { + compilerOptions: { + strict: true, + target: "ESNext", + module: "ESNext", + moduleResolution: "Bundler", + skipLibCheck: true, + }, + }, + null, + 2, + )}\n`; +} + +function agentsMd(): string { + return `# AGENTS + +Placeholder: coding agent instructions for this workflow workspace will be added in a later phase. +`; +} + +function readmeMd(workspaceName: string): string { + return `# ${workspaceName} + +Local workflow development workspace (Bun monorepo). + +## Layout + +- \`templates/\` — reusable workflow definition packages (roles + moderator), no agent binding +- \`workflows/\` — workflow instances that bind templates to agents and export \`run\` + \`descriptor\` + +## Commands + +\`\`\`sh +bun install +bun run check # after you add scripts / Biome +uncaged-workflow add +uncaged-workflow run +\`\`\` + +Create this skeleton with: + +\`\`\`sh +uncaged-workflow init workspace ${workspaceName} +\`\`\` +`; +} + +export async function cmdInitWorkspace( + parentDir: string, + workspaceName: string, +): Promise> { + const validated = validateWorkspaceSegment(workspaceName); + if (!validated.ok) { + return validated; + } + + const rootPath = join(parentDir, workspaceName); + if (await pathExists(rootPath)) { + return err(`directory already exists: ${rootPath}`); + } + + await mkdir(rootPath, { recursive: false }); + await mkdir(join(rootPath, "templates"), { recursive: false }); + await mkdir(join(rootPath, "workflows"), { recursive: false }); + + await Promise.all([ + writeFile(join(rootPath, "package.json"), rootPackageJson(workspaceName), "utf8"), + writeFile(join(rootPath, "biome.json"), biomeJson(), "utf8"), + writeFile(join(rootPath, "tsconfig.json"), tsconfigJson(), "utf8"), + writeFile(join(rootPath, "AGENTS.md"), agentsMd(), "utf8"), + writeFile(join(rootPath, "README.md"), readmeMd(workspaceName), "utf8"), + writeFile(join(rootPath, "templates", ".gitkeep"), "", "utf8"), + writeFile(join(rootPath, "workflows", "package.json"), workflowsPackageJson(), "utf8"), + ]); + + return ok({ rootPath }); +} + +export function cmdInitTemplate(_parentDir: string, _templateName: string): Result { + return err("not implemented yet"); +} From 703ac9dfccdb53131cf01401d522cd4017223cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Thu, 7 May 2026 21:21:23 +0800 Subject: [PATCH 2/3] feat(cli): add init template command (#36 Phase 2) - Implement cmdInitTemplate: find workspace root, generate template package - Generate roles.ts, moderator.ts, index.ts with hello-world boilerplate - Detect workspace by walking up to find package.json with workspaces - Error on existing template dir or outside workspace - Add init-template.test.ts Testing: #47 --- .../__tests__/init-template.test.ts | 142 +++++++++++++ .../__tests__/init-workspace.test.ts | 15 +- packages/cli-workflow/src/cli-dispatch.ts | 3 +- packages/cli-workflow/src/cmd-init.ts | 188 +++++++++++++++++- 4 files changed, 329 insertions(+), 19 deletions(-) create mode 100644 packages/cli-workflow/__tests__/init-template.test.ts diff --git a/packages/cli-workflow/__tests__/init-template.test.ts b/packages/cli-workflow/__tests__/init-template.test.ts new file mode 100644 index 0000000..4db9614 --- /dev/null +++ b/packages/cli-workflow/__tests__/init-template.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { runCli } from "../src/cli-dispatch.js"; +import { cmdInitTemplate, cmdInitWorkspace } from "../src/cmd-init.js"; +import { pathExists } from "../src/fs-utils.js"; + +describe("init template", () => { + let parent: string; + + beforeEach(async () => { + parent = join( + tmpdir(), + `wf-init-template-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + await mkdir(parent, { recursive: true }); + }); + + afterEach(async () => { + await rm(parent, { recursive: true, force: true }); + }); + + test("creates templates/ with expected files", async () => { + const ws = await cmdInitWorkspace(parent, "my-workflows"); + expect(ws.ok).toBe(true); + if (!ws.ok) { + return; + } + const root = ws.value.rootPath; + + const created = await cmdInitTemplate(root, "review-pr"); + expect(created.ok).toBe(true); + if (!created.ok) { + return; + } + + const tdir = join(root, "templates", "review-pr"); + expect(created.value.templatePath).toBe(tdir); + expect(await pathExists(join(tdir, "package.json"))).toBe(true); + expect(await pathExists(join(tdir, "tsconfig.json"))).toBe(true); + expect(await pathExists(join(tdir, "src", "roles.ts"))).toBe(true); + expect(await pathExists(join(tdir, "src", "moderator.ts"))).toBe(true); + expect(await pathExists(join(tdir, "src", "index.ts"))).toBe(true); + + const pkg = JSON.parse(await readFile(join(tdir, "package.json"), "utf8")) as { + name: string; + type: string; + dependencies: Record; + }; + expect(pkg.type).toBe("module"); + expect(pkg.dependencies["@uncaged/workflow"]).toBeDefined(); + expect(pkg.dependencies.zod).toBeDefined(); + expect(pkg.name).toContain("review-pr"); + + const idx = await readFile(join(tdir, "src", "index.ts"), "utf8"); + expect(idx).toContain("WorkflowDefinition"); + + const roles = await readFile(join(tdir, "src", "roles.ts"), "utf8"); + expect(roles).not.toContain("interface "); + expect(roles).not.toContain("?:"); + expect(roles).not.toContain("export default"); + + const moder = await readFile(join(tdir, "src", "moderator.ts"), "utf8"); + expect(moder).not.toContain("export default"); + }); + + test("finds workspace walking up from nested cwd", async () => { + const ws = await cmdInitWorkspace(parent, "ws"); + expect(ws.ok).toBe(true); + if (!ws.ok) { + return; + } + const root = ws.value.rootPath; + const nested = join(root, "a", "b"); + await mkdir(nested, { recursive: true }); + + const created = await cmdInitTemplate(nested, "nested-tpl"); + expect(created.ok).toBe(true); + if (!created.ok) { + return; + } + expect(await pathExists(join(root, "templates", "nested-tpl", "src", "index.ts"))).toBe(true); + }); + + test("errors when not inside a workflow workspace", async () => { + const orphan = join(parent, "nowhere"); + await mkdir(orphan, { recursive: true }); + const r = await cmdInitTemplate(orphan, "x"); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toContain("templates/*"); + } + }); + + test("errors when template directory already exists", async () => { + const ws = await cmdInitWorkspace(parent, "ws"); + expect(ws.ok).toBe(true); + if (!ws.ok) { + return; + } + const root = ws.value.rootPath; + + const first = await cmdInitTemplate(root, "dup"); + expect(first.ok).toBe(true); + + const second = await cmdInitTemplate(root, "dup"); + expect(second.ok).toBe(false); + if (!second.ok) { + expect(second.error).toContain("already exists"); + } + }); + + test("errors on invalid template name", async () => { + const ws = await cmdInitWorkspace(parent, "ws"); + expect(ws.ok).toBe(true); + if (!ws.ok) { + return; + } + const bad = await cmdInitTemplate(ws.value.rootPath, "a/b"); + expect(bad.ok).toBe(false); + }); + + test.serial("runCli init template uses cwd and succeeds in workspace", async () => { + const ws = await cmdInitWorkspace(parent, "cli-ws"); + expect(ws.ok).toBe(true); + if (!ws.ok) { + return; + } + const root = ws.value.rootPath; + const prev = process.cwd(); + try { + process.chdir(root); + const code = await runCli(join(parent, "_storage"), ["init", "template", "from-cli"]); + expect(code).toBe(0); + expect(await pathExists(join(root, "templates", "from-cli", "package.json"))).toBe(true); + } finally { + process.chdir(prev); + } + }); +}); diff --git a/packages/cli-workflow/__tests__/init-workspace.test.ts b/packages/cli-workflow/__tests__/init-workspace.test.ts index 1589796..a5a1bc9 100644 --- a/packages/cli-workflow/__tests__/init-workspace.test.ts +++ b/packages/cli-workflow/__tests__/init-workspace.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { formatCliUsage, runCli } from "../src/cli-dispatch.js"; -import { cmdInitTemplate, cmdInitWorkspace } from "../src/cmd-init.js"; +import { cmdInitWorkspace } from "../src/cmd-init.js"; import { pathExists } from "../src/fs-utils.js"; describe("init workspace", () => { @@ -85,14 +85,6 @@ describe("init workspace", () => { expect(u).toContain("uncaged-workflow init template "); }); - test("init template command is stubbed", () => { - const r = cmdInitTemplate(parent, "x"); - expect(r.ok).toBe(false); - if (!r.ok) { - expect(r.error).toBe("not implemented yet"); - } - }); - test("runCli rejects unknown init subcommand", async () => { const code = await runCli(join(parent, "_storage"), ["init", "bogus", "name"]); expect(code).toBe(1); @@ -109,9 +101,4 @@ describe("init workspace", () => { process.chdir(prev); } }); - - test("runCli init template exits with error", async () => { - const code = await runCli(join(parent, "_storage"), ["init", "template", "t"]); - expect(code).toBe(1); - }); }); diff --git a/packages/cli-workflow/src/cli-dispatch.ts b/packages/cli-workflow/src/cli-dispatch.ts index 4151631..d40fe07 100644 --- a/packages/cli-workflow/src/cli-dispatch.ts +++ b/packages/cli-workflow/src/cli-dispatch.ts @@ -65,11 +65,12 @@ async function dispatchInit(_storageRoot: string, argv: string[]): Promise { if (name.length === 0) { return err("workspace name must not be empty"); @@ -155,6 +159,182 @@ export async function cmdInitWorkspace( return ok({ rootPath }); } -export function cmdInitTemplate(_parentDir: string, _templateName: string): Result { - return err("not implemented yet"); +function hasTemplatesWorkspaceGlob(workspaces: unknown): boolean { + return Array.isArray(workspaces) && workspaces.includes("templates/*"); +} + +async function readPackageJsonWorkspaces(dir: string): Promise { + const pkgPath = join(dir, "package.json"); + if (!(await pathExists(pkgPath))) { + return null; + } + let raw: string; + try { + raw = await readFile(pkgPath, "utf8"); + } catch { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null || !("workspaces" in parsed)) { + return null; + } + return (parsed as { workspaces: unknown }).workspaces; +} + +/** Resolve uncaged-workflow workspace root (package.json with `templates/*` in `workspaces`). */ +async function findWorkflowWorkspaceRoot(startDir: string): Promise> { + let dir = resolve(startDir); + for (;;) { + const workspaces = await readPackageJsonWorkspaces(dir); + if (workspaces !== null && hasTemplatesWorkspaceGlob(workspaces)) { + return ok(dir); + } + const parent = dirname(dir); + if (parent === dir) { + return err( + 'not inside a workflow workspace (no package.json with workspaces containing "templates/*")', + ); + } + dir = parent; + } +} + +function templatePackageJson(templateName: string): string { + return `${JSON.stringify( + { + name: `template-${templateName}`, + version: "0.0.0", + private: true, + type: "module", + dependencies: { + "@uncaged/workflow": "^0.1.0", + zod: "^4.0.0", + }, + }, + null, + 2, + )}\n`; +} + +function templateTsconfigJson(): string { + return `${JSON.stringify( + { + extends: "../../tsconfig.json", + compilerOptions: { + rootDir: "src", + outDir: "dist", + }, + include: ["src/**/*.ts"], + }, + null, + 2, + )}\n`; +} + +function templateRolesTs(): string { + return `import type { RoleDefinition } from "@uncaged/workflow"; +import * as z from "zod/v4"; + +export const HELLO_TEMPLATE_DESCRIPTION = + "Minimal starter template: one greeter role, then END."; + +export type HelloTemplateMeta = { + greeter: { + message: string; + }; +}; + +const greeterMetaSchema = z.object({ + message: z.string(), +}); + +export const greeterRole: RoleDefinition = { + description: "Says hello — replace with your first role.", + systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.", + extractPrompt: "Extract the assistant's greeting as message.", + schema: greeterMetaSchema, + extractRefs: null, +}; +`; +} + +function templateModeratorTs(): string { + return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow"; + +import type { HelloTemplateMeta } from "./roles.js"; + +export const helloTemplateModerator: Moderator = ( + ctx: ModeratorContext, +) => { + if (ctx.steps.length === 0) { + return "greeter"; + } + return END; +}; +`; +} + +function templateIndexTs(): string { + return `import type { WorkflowDefinition } from "@uncaged/workflow"; + +import { helloTemplateModerator } from "./moderator.js"; +import { + HELLO_TEMPLATE_DESCRIPTION, + type HelloTemplateMeta, + greeterRole, +} from "./roles.js"; + +export { + HELLO_TEMPLATE_DESCRIPTION, + type HelloTemplateMeta, + greeterRole, +} from "./roles.js"; +export { helloTemplateModerator } from "./moderator.js"; + +export const helloTemplateWorkflowDefinition: WorkflowDefinition = { + description: HELLO_TEMPLATE_DESCRIPTION, + roles: { + greeter: greeterRole, + }, + moderator: helloTemplateModerator, +}; +`; +} + +export async function cmdInitTemplate( + startDir: string, + templateName: string, +): Promise> { + const validated = validateWorkspaceSegment(templateName); + if (!validated.ok) { + return validated; + } + + const rootResult = await findWorkflowWorkspaceRoot(startDir); + if (!rootResult.ok) { + return rootResult; + } + + const workspaceRoot = rootResult.value; + const templateDir = join(workspaceRoot, "templates", templateName); + if (await pathExists(templateDir)) { + return err(`template already exists: ${templateDir}`); + } + + await mkdir(join(templateDir, "src"), { recursive: true }); + + await Promise.all([ + writeFile(join(templateDir, "package.json"), templatePackageJson(templateName), "utf8"), + writeFile(join(templateDir, "tsconfig.json"), templateTsconfigJson(), "utf8"), + writeFile(join(templateDir, "src", "roles.ts"), templateRolesTs(), "utf8"), + writeFile(join(templateDir, "src", "moderator.ts"), templateModeratorTs(), "utf8"), + writeFile(join(templateDir, "src", "index.ts"), templateIndexTs(), "utf8"), + ]); + + return ok({ templatePath: templateDir }); } From 74e3f5434c8e749ac4ac0491794e807e0a608e58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Thu, 7 May 2026 21:23:41 +0800 Subject: [PATCH 3/3] feat(cli): complete AGENTS.md generation (#36 Phase 3) - Replace placeholder with comprehensive coding agent instructions - Covers: project structure, core concepts, dev workflow, coding conventions, template reuse, build/test, common pitfalls - Add test coverage for AGENTS.md sections and terms Testing: #48 --- .../__tests__/init-workspace.test.ts | 48 +++++++++++ packages/cli-workflow/src/cmd-init.ts | 79 ++++++++++++++++++- 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/packages/cli-workflow/__tests__/init-workspace.test.ts b/packages/cli-workflow/__tests__/init-workspace.test.ts index a5a1bc9..ba3e30e 100644 --- a/packages/cli-workflow/__tests__/init-workspace.test.ts +++ b/packages/cli-workflow/__tests__/init-workspace.test.ts @@ -57,6 +57,54 @@ describe("init workspace", () => { expect(tsconfig.compilerOptions.target).toBe("ESNext"); }); + test("AGENTS.md contains coding agent guide sections and terms", async () => { + const created = await cmdInitWorkspace(parent, "my-workflows"); + expect(created.ok).toBe(true); + if (!created.ok) { + return; + } + + const agentsPath = join(created.value.rootPath, "AGENTS.md"); + const body = await readFile(agentsPath, "utf8"); + + for (const section of [ + "项目结构", + "核心概念", + "开发流程", + "编码规范", + "Template", + "Build", + "常见陷阱", + ]) { + expect(body).toContain(section); + } + + for (const term of [ + "RoleDefinition", + "WorkflowDefinition", + "Moderator", + "AgentFn", + "ExtractFn", + "RoleMeta", + ]) { + expect(body).toContain(term); + } + + expect(body).toMatch(/type[\s\S]*interface/i); + expect(body).toMatch(/function[\s\S]*class/i); + expect(body).toContain("Crockford Base32"); + expect(body).toMatch(/no[\s\S]*default export/i); + expect(body).toMatch(/no[\s\S]*console/i); + expect(body).toMatch(/no[\s\S]*dynamic import/i); + + expect(body).toContain("bun run check"); + expect(body).toContain("bun test"); + expect(body).toContain("uncaged-workflow"); + expect(body).toContain("bun build"); + expect(body).toContain("CLAUDE.md"); + expect(body).toContain("docs/architecture.md"); + }); + test("errors when directory already exists", async () => { const first = await cmdInitWorkspace(parent, "dup"); expect(first.ok).toBe(true); diff --git a/packages/cli-workflow/src/cmd-init.ts b/packages/cli-workflow/src/cmd-init.ts index 248c32d..c30ca68 100644 --- a/packages/cli-workflow/src/cmd-init.ts +++ b/packages/cli-workflow/src/cmd-init.ts @@ -95,9 +95,84 @@ function tsconfigJson(): string { } function agentsMd(): string { - return `# AGENTS + return `# AGENTS — Workflow 工作区开发指南 -Placeholder: coding agent instructions for this workflow workspace will be added in a later phase. +面向在本仓库中编写 workflow 的 coding agent。引擎层术语与架构细节与 **@uncaged/workflow** 上游文档一致,编写时可对照 \`CLAUDE.md\` 与 \`docs/architecture.md\`。 + +## 1. 项目结构(workspace / template / workflow instance) + +| 层级 | 目录 / 产物 | 职责 | +|------|----------------|------| +| **Workspace** | 仓库根(\`package.json\` 含 \`workspaces: ["templates/*", "workflows"]\`) | Bun monorepo:统一管理本地模板包与 workflow 实例 | +| **Template** | \`templates//\`(如 \`src/roles.ts\`、\`src/moderator.ts\`、\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **Moderator**),**不绑定**具体 Agent | +| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AgentFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) | + +Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下放绑定与打包入口。 + +## 2. 核心概念 + +- **RoleMeta**:\`Record>\`,角色名 → 该角色结构化 meta 的形状约定。 +- **RoleDefinition**:纯数据——\`description\`、\`systemPrompt\`、\`extractPrompt\`、\`schema\`(Zod v4)。不含执行逻辑。 +- **WorkflowDefinition**:\`description\` + \`roles\`(各角色定义)+ **Moderator**。 +- **Moderator**:\`(ctx: ModeratorContext) => (角色名) | END\`。同步、纯函数,只做路由。 +- **AgentFn**:\`(ctx: AgentContext) => Promise\`,原始文本输出;从上下文读取当前角色的 \`systemPrompt\`。 +- **ExtractFn**:从上下文与 prompt 解析结构化数据(引擎与 Agent 都可使用)。 + +引擎循环简述:**Moderator** → 选角色 → **Agent** 产出文本 → **Extract** 写入 **meta** → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。 + +## 3. 开发流程 + +1. **定义 RoleMeta**:为每个角色约定 meta 的 TypeScript 类型(与 Zod schema 对齐)。 +2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`extractPrompt\` / \`description\`。 +3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`。 +4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。 +5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding, extract)\`(或项目约定的封装)绑定 **AgentFn** / **ExtractFn**。 +6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。 + +## 4. 编码规范 + +与 **CLAUDE.md** 对齐,摘要如下: + +- **Functional-first**:优先 \`function\` + \`type\`,避免面向对象业务模型。 +- **type 而非 interface**:类型别名一律用 \`type\`,不要使用 \`interface\`。 +- **显式可空**:不要用 \`?:\`;可空字段写成 \`T | null\`。 +- **function 而非 class**:不用 class(第三方库要求或 \`Error\` 子类除外)。 +- **Crockford Base32**:日志 tag、bundle hash、thread id 等标识约定(引擎侧);工作区内自定义日志若沿用引擎 logger,tag 为 8 字符 Crockford Base32,且每个调用点唯一。 +- **Named exports only**:不要使用 **default export**;workflow bundle 须 **export const run** 与 **export const descriptor**。 +- **No console.log**:库代码用结构化 logger;CLI 用户输出可按项目 Biome 规则例外标注。 +- **No dynamic import**:业务与 bundle 内禁止 \`import()\`;例外仅限「运行时路径由用户提供的 bundle 加载器」(引擎内部)。 + +## 5. Template 复用 + +- **已发布模板**:可通过 npm 依赖 \`@uncaged/workflow-template-*\` 等包,在 workflow 实例中 import 其 **WorkflowDefinition** 再绑定 Agent。 +- **本地模板**:放在本仓库 \`templates//\`,由 workspace 协议引用(如 \`"template-foo": "workspace:*"\` 或相对路径),便于同源修改与版本控制。 + +选择模板时保持 **definition 与 agent 绑定分离**:模板只描述「做什么、顺序如何」,实例决定「谁执行、如何抽取 meta」。 + +## 6. Build and Test + +日常命令: + +\`\`\`sh +bun install +bun run check # Biome:lint + format +bun test +bun build # 若包内配置了 build 脚本则用于产出 dist / bundle +uncaged-workflow add +\`\`\` + +提交前至少运行 **bun run check** 与 **bun test**;registry 与本地运行流参见 README 与 CLI 文档。 + +## 7. 常见陷阱 + +- **No dynamic import**:bundle 须静态可分析;动态 \`import()\` 会破坏哈希与加载约束。 +- **No default export**:引擎只接受命名导出 \`run\` / \`descriptor\`。 +- **No console.log**:避免在可被 Biome \`noConsole\` 规则覆盖的代码路径直接使用 console。 +- **Single-file ESM bundle**:交付物是单一 \`.esm.js\`;静态 import 仅限 Node 内置(见 architecture 文档中的 Bundle Contract)。 + +--- + +编写新 workflow 时,先对齐 **RoleMeta → RoleDefinition(Zod)→ Moderator → 绑定 → 单文件 bundle**,再对照本节规范自检。 `; }