diff --git a/packages/cli-workflow/src/__tests__/include-tag.test.ts b/packages/cli-workflow/src/__tests__/include-tag.test.ts new file mode 100644 index 0000000..8c738ed --- /dev/null +++ b/packages/cli-workflow/src/__tests__/include-tag.test.ts @@ -0,0 +1,60 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { parse } from "yaml"; +import { createIncludeTag } from "../include.js"; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "include-tag-test-")); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +describe("!include tag", () => { + test("includes .md file as string", async () => { + await writeFile(join(tmpDir, "prompt.md"), "You are an analyst."); + const yaml = "system: !include prompt.md"; + const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] }); + expect(result.system).toBe("You are an analyst."); + }); + + test("includes .json file as parsed object", async () => { + await writeFile(join(tmpDir, "schema.json"), '{"type":"object","properties":{}}'); + const yaml = "outputSchema: !include schema.json"; + const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] }); + expect(result.outputSchema).toEqual({ type: "object", properties: {} }); + }); + + test("includes .yaml file as parsed object", async () => { + await writeFile(join(tmpDir, "config.yaml"), "key: value\nlist:\n - a\n - b"); + const yaml = "config: !include config.yaml"; + const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] }); + expect(result.config).toEqual({ key: "value", list: ["a", "b"] }); + }); + + test("resolves relative subdirectory paths", async () => { + const subdir = join(tmpDir, "roles"); + await mkdir(subdir, { recursive: true }); + await writeFile(join(subdir, "analyst.md"), "Analyze data."); + const yaml = "system: !include roles/analyst.md"; + const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] }); + expect(result.system).toBe("Analyze data."); + }); + + test("throws on missing file", () => { + const yaml = "system: !include nonexistent.md"; + expect(() => parse(yaml, { customTags: [createIncludeTag(tmpDir)] })).toThrow(); + }); + + test("includes .txt file as string", async () => { + await writeFile(join(tmpDir, "note.txt"), "Hello world"); + const yaml = "note: !include note.txt"; + const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] }); + expect(result.note).toBe("Hello world"); + }); +}); diff --git a/packages/cli-workflow/src/__tests__/workflow-resolution.test.ts b/packages/cli-workflow/src/__tests__/workflow-resolution.test.ts index 9c38ae0..d8d8106 100644 --- a/packages/cli-workflow/src/__tests__/workflow-resolution.test.ts +++ b/packages/cli-workflow/src/__tests__/workflow-resolution.test.ts @@ -257,6 +257,43 @@ describe("Strategy 3: Local Discovery", () => { expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); }); + + test("should find workflow in folder-based layout (name/index.yaml)", async () => { + await makeUwfStore(storageRoot); + const workflowDir = join(projectRoot, ".workflow", "solve-issue"); + await mkdir(workflowDir, { recursive: true }); + await writeFile(join(workflowDir, "index.yaml"), await createWorkflowYaml("solve-issue")); + + const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot); + + expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + const uwf = await makeUwfStore(storageRoot); + const node = uwf.store.get(result.workflow); + expect(node).not.toBeNull(); + if (node !== null) { + expect((node.payload as WorkflowPayload).name).toBe("solve-issue"); + } + }); + + test("should prefer flat file over folder-based layout", async () => { + await makeUwfStore(storageRoot); + const workflowDir = join(projectRoot, ".workflow"); + await mkdir(workflowDir, { recursive: true }); + await writeFile(join(workflowDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue", "flat")); + + const folderDir = join(workflowDir, "solve-issue"); + await mkdir(folderDir, { recursive: true }); + await writeFile(join(folderDir, "index.yaml"), await createWorkflowYaml("solve-issue", "folder")); + + const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot); + + const uwf = await makeUwfStore(storageRoot); + const node = uwf.store.get(result.workflow); + expect(node).not.toBeNull(); + if (node !== null) { + expect((node.payload as WorkflowPayload).description).toBe("Test workflow (flat)"); + } + }); }); // ── Strategy 4: Global Registry Fallback ────────────────────────────────────── diff --git a/packages/cli-workflow/src/commands/thread.ts b/packages/cli-workflow/src/commands/thread.ts index 67dc39e..16a4c8c 100644 --- a/packages/cli-workflow/src/commands/thread.ts +++ b/packages/cli-workflow/src/commands/thread.ts @@ -27,6 +27,7 @@ import type { AdapterOutput } from "@uncaged/workflow-util-agent"; import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-util-agent"; import { config as loadDotenv } from "dotenv"; import { parse } from "yaml"; +import { createIncludeTag } from "../include.js"; import { createMarker, deleteMarker, isThreadRunning } from "../background/index.js"; import { evaluate } from "../moderator/index.js"; import { @@ -118,6 +119,10 @@ async function findWorkflowInDir(dir: string, name: string): Promise { const dir = join(projectRoot, ".workflows"); - let entries: string[]; + let dirents: Dirent[]; try { - entries = await readdir(dir); + dirents = await readdir(dir, { withFileTypes: true }); } catch (e) { const err = e as NodeJS.ErrnoException; if (err.code === "ENOENT" || err.code === "ENOTDIR") { @@ -39,12 +40,22 @@ export async function discoverProjectWorkflows( } const result: ProjectWorkflowEntry[] = []; - for (const entry of entries) { - if (!entry.endsWith(".yaml") && !entry.endsWith(".yml")) { - continue; + for (const entry of dirents) { + if (entry.isFile() && (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml"))) { + const stem = entry.name.endsWith(".yaml") ? entry.name.slice(0, -5) : entry.name.slice(0, -4); + result.push({ name: stem, filePath: join(dir, entry.name) }); + } else if (entry.isDirectory()) { + for (const indexName of ["index.yaml", "index.yml"]) { + const indexPath = join(dir, entry.name, indexName); + try { + await access(indexPath); + result.push({ name: entry.name, filePath: indexPath }); + break; + } catch { + // not found, try next + } + } } - const stem = entry.endsWith(".yaml") ? entry.slice(0, -5) : entry.slice(0, -4); - result.push({ name: stem, filePath: join(dir, entry) }); } return result; } diff --git a/packages/cli-workflow/src/validate.ts b/packages/cli-workflow/src/validate.ts index bf94022..76683c6 100644 --- a/packages/cli-workflow/src/validate.ts +++ b/packages/cli-workflow/src/validate.ts @@ -1,4 +1,4 @@ -import { basename } from "node:path"; +import { basename, dirname } from "node:path"; import type { CasRef, WorkflowPayload } from "@uncaged/workflow-protocol"; const CAS_REF_PATTERN = /^[0-9A-HJKMNP-TV-Z]{13}$/; @@ -68,9 +68,11 @@ function isGraph(value: unknown): boolean { */ export function workflowNameFromPath(filePath: string): string { const base = basename(filePath); - if (base.endsWith(".yaml")) return base.slice(0, -5); - if (base.endsWith(".yml")) return base.slice(0, -4); - return base; + const stem = base.endsWith(".yaml") ? base.slice(0, -5) : base.endsWith(".yml") ? base.slice(0, -4) : base; + if (stem === "index") { + return basename(dirname(filePath)); + } + return stem; } /**