diff --git a/biome.json b/biome.json index b1d0c18..4cc600d 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.14/schema.json", "files": { "includes": [ "**", 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..a061c06 --- /dev/null +++ b/packages/cli-workflow/src/__tests__/include-tag.test.ts @@ -0,0 +1,84 @@ +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"); + }); + + test("blocks path traversal with ../", async () => { + const yaml = "secret: !include ../../etc/passwd"; + expect(() => parse(yaml, { customTags: [createIncludeTag(tmpDir)] })).toThrow( + /path traversal blocked/, + ); + }); + + test("blocks absolute path traversal", async () => { + const yaml = "secret: !include /etc/passwd"; + expect(() => parse(yaml, { customTags: [createIncludeTag(tmpDir)] })).toThrow( + /path traversal blocked/, + ); + }); + + test("supports nested !include in yaml files", async () => { + const subdir = join(tmpDir, "parts"); + await mkdir(subdir, { recursive: true }); + await writeFile(join(subdir, "inner.md"), "nested content"); + await writeFile(join(tmpDir, "outer.yaml"), "value: !include parts/inner.md"); + const yaml = "config: !include outer.yaml"; + const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] }); + expect(result.config).toEqual({ value: "nested content" }); + }); +}); diff --git a/packages/cli-workflow/src/__tests__/workflow-resolution.test.ts b/packages/cli-workflow/src/__tests__/workflow-resolution.test.ts index 9c38ae0..664c265 100644 --- a/packages/cli-workflow/src/__tests__/workflow-resolution.test.ts +++ b/packages/cli-workflow/src/__tests__/workflow-resolution.test.ts @@ -257,6 +257,49 @@ 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..dad5e31 100644 --- a/packages/cli-workflow/src/commands/thread.ts +++ b/packages/cli-workflow/src/commands/thread.ts @@ -28,6 +28,7 @@ import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-util-agent"; import { config as loadDotenv } from "dotenv"; import { parse } from "yaml"; import { createMarker, deleteMarker, isThreadRunning } from "../background/index.js"; +import { createIncludeTag } from "../include.js"; import { evaluate } from "../moderator/index.js"; import { appendThreadHistory, @@ -118,6 +119,15 @@ async function findWorkflowInDir(dir: string, name: string): Promise { + for (const indexName of ["index.yaml", "index.yml"]) { + const indexPath = join(dir, dirName, indexName); + try { + await access(indexPath); + return { name: dirName, filePath: indexPath }; + } catch { + // not found, try next + } + } + return null; +} + /** - * Scan `/.workflows/*.yaml` (non-recursive) and return discovered entries. - * Returns an empty array if the directory does not exist. + * Scan a single directory for workflow entries (flat YAML files + folder/index.yaml). + * Returns discovered entries. Returns empty array if directory does not exist. */ -export async function discoverProjectWorkflows( - projectRoot: string, -): Promise { - const dir = join(projectRoot, ".workflows"); - let entries: string[]; +async function scanWorkflowDir(dir: string): Promise { + 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,16 +61,39 @@ 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"))) { + result.push({ name: stemFromYaml(entry.name), filePath: join(dir, entry.name) }); + } else if (entry.isDirectory()) { + const found = await findIndexWorkflow(dir, entry.name); + if (found !== null) { + result.push(found); + } } - const stem = entry.endsWith(".yaml") ? entry.slice(0, -5) : entry.slice(0, -4); - result.push({ name: stem, filePath: join(dir, entry) }); } return result; } +/** + * Scan `/.workflow/` (preferred) and `.workflows/` (legacy) for workflow entries. + * .workflow/ takes priority: if a name is found in both, .workflow/ wins. + * Returns an empty array if neither directory exists. + */ +export async function discoverProjectWorkflows( + projectRoot: string, +): Promise { + const primary = await scanWorkflowDir(join(projectRoot, ".workflow")); + const legacy = await scanWorkflowDir(join(projectRoot, ".workflows")); + const seen = new Set(primary.map((e) => e.name)); + const merged = [...primary]; + for (const entry of legacy) { + if (!seen.has(entry.name)) { + merged.push(entry); + } + } + return merged; +} + /** Default filesystem root for uwf data (`~/.uncaged/workflow`). */ export function getDefaultStorageRoot(): string { return join(homedir(), ".uncaged", "workflow"); diff --git a/packages/cli-workflow/src/validate.ts b/packages/cli-workflow/src/validate.ts index bf94022..83a68aa 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,15 @@ 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; } /**