diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 04e576d..473b70f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,7 @@ Bump size is controlled by PR labels (`major` > `minor` > patch default). See [P | `agents/` | Shared `createAgent`, Docker invoke, image ensure/build | | `agents/base/` | Shared Arch base image constants (`agent-gwt/base:local`) | | `agents/cursor/` | Cursor bindings only (Dockerfile path, image, auth, CLI run) | -| `agents/claude/` | Claude Code bindings only (Dockerfile path, image, credentials, CLI run) | +| `agents/claude/` | Claude Code bindings only (Dockerfile path, image, credentials, CLI run) | | `docker/base/` | Shared Arch + yay Dockerfile (all agents `FROM` this tag) | | `docker//` | Per-agent Dockerfile (`FROM agent-gwt/base:local` + that product’s CLI) | | `package-root` | Relative resolve to this package’s root (`src/` or `lib/` parent) — no directory scans | diff --git a/README.md b/README.md index b64fe6a..785c6a2 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,33 @@ async function question_is_answered(this: Context) { } ``` +To copy a fixture tree instead of writing files one by one, use `copy_to_workspace`. Globs are resolved from the **current Vitest spec file’s directory** (not `process.cwd()`). A relative `from` is also resolved against that spec directory (absolute `from` is used as-is): + +```ts +import { join } from "node:path"; +import { copy_to_workspace } from "agent-gwt"; + +async function tests_are_in_workspace(this: Context) { + await copy_to_workspace(this.workspace, ["fixtures/**/*.spec.ts"]); +} + +async function shared_fixtures_are_in_workspace(this: Context) { + await copy_to_workspace(this.workspace, ["**/*.spec.ts"], { + from: join(import.meta.dirname, "../shared-fixtures"), + }); +} +``` + +Optional `base` strips a matching path prefix (`*` = one folder segment). `base: "fixtures/*"` turns `fixtures/suite-a/foo.spec.ts` into `foo.spec.ts` and `fixtures/suite-b/nested/bar.spec.ts` into `nested/bar.spec.ts`. + +```ts +await copy_to_workspace(this.workspace, ["fixtures/**/*.spec.ts"], { + base: "fixtures/*", +}); +``` + +An empty `globs` array, a glob that matches no files, a `base` that does not match every file, or two sources mapping to the same destination all throw, and nothing is copied. + ### Inspecting the result `this.agentResult` is the parsed JSON the CLI printed, for either agent. For Claude Code, `ClaudeAgentResult` types the useful fields: @@ -194,11 +221,11 @@ A Claude run whose JSON reports `is_error: true` throws from `executing_the_agen ## Docker images -| Image | Role | -| --- | --- | -| `agent-gwt/base:local` | Shared Arch Linux base (`yay` + `aur` user). Used by all agents. | -| `agent-gwt/cursor-cli:local` | Cursor CLI on top of the base | -| `agent-gwt/claude-code:local` | Claude Code CLI (native binary) on top of the base | +| Image | Role | +| ----------------------------- | ---------------------------------------------------------------- | +| `agent-gwt/base:local` | Shared Arch Linux base (`yay` + `aur` user). Used by all agents. | +| `agent-gwt/cursor-cli:local` | Cursor CLI on top of the base | +| `agent-gwt/claude-code:local` | Claude Code CLI (native binary) on top of the base | `buildAgentImage("cursor")` builds the base first, then the Cursor image; `buildAgentImage("claude")` does the same for Claude Code. @@ -270,19 +297,20 @@ Pair workspace lifecycle separately: `withAspect(a_workspace, cleanup_workspace) ## Exports -| Export | Role | -| --- | --- | -| `AgentContext` | Extensible context type (`workspace`, `prompt`, `agent`, `image`, …) | -| `agent(opts)` | `withAspect` before — `{ name: "cursor" \| "claude", model?, image? }` | -| `buildAgentImage(name)` | Suite setup — builds base + agent image (use in vitest `globalSetup`) | -| `buildBaseImage()` | Builds `agent-gwt/base:local` only | -| `buildDockerImage(...)` | Builds an arbitrary Dockerfile (e.g. toolchain overlay) | -| `a_workspace` | Creates `/tmp/.agents-gwt/ws-*` (use in `withAspect` before, or in `given`) | -| `cleanup_workspace` | Remove the temp workspace (use in `withAspect` after) | -| `the_prompt(text)` | Curried `given` — sets `this.prompt` | -| `executing_the_agent` | `when` — runs `this.agent.run(...)` | -| `ClaudeAgentResult` | Type for Claude Code's JSON result (`is_error`, `result`, `num_turns`, `total_cost_usd`, …) | -| `ClaudeCredentials` / `resolveClaudeCredentials()` | Credential source for the Claude container (token, API key, or file) | +| Export | Role | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `AgentContext` | Extensible context type (`workspace`, `prompt`, `agent`, `image`, …) | +| `agent(opts)` | `withAspect` before — `{ name: "cursor" \| "claude", model?, image? }` | +| `buildAgentImage(name)` | Suite setup — builds base + agent image (use in vitest `globalSetup`) | +| `buildBaseImage()` | Builds `agent-gwt/base:local` only | +| `buildDockerImage(...)` | Builds an arbitrary Dockerfile (e.g. toolchain overlay) | +| `a_workspace` | Creates `/tmp/.agents-gwt/ws-*` (use in `withAspect` before, or in `given`) | +| `copy_to_workspace(workspace, globs, options?)` | Copy glob-matched files into `workspace` from the current spec directory (`from`, `base`) | +| `cleanup_workspace` | Remove the temp workspace (use in `withAspect` after) | +| `the_prompt(text)` | Curried `given` — sets `this.prompt` | +| `executing_the_agent` | `when` — runs `this.agent.run(...)` | +| `ClaudeAgentResult` | Type for Claude Code's JSON result (`is_error`, `result`, `num_turns`, `total_cost_usd`, …) | +| `ClaudeCredentials` / `resolveClaudeCredentials()` | Credential source for the Claude container (token, API key, or file) | ## Isolation notes diff --git a/package.json b/package.json index f15f2bb..0ff98c9 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,7 @@ "vite": "catalog:", "vite-plus": "catalog:", "vitest": "catalog:", - "vitest-gwt": "^4.1.3", + "vitest-gwt": "^4.1.4", "wireit": "^0.14.13" }, "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c409b5d..fca98b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,8 +46,8 @@ importers: specifier: 'catalog:' version: 4.1.11(@types/node@26.4.0)(@vitest/browser-preview@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2))(vitest@4.1.11))(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2)) vitest-gwt: - specifier: ^4.1.3 - version: 4.1.3(vitest@4.1.11) + specifier: ^4.1.4 + version: 4.1.4(vitest@4.1.11) wireit: specifier: ^0.14.13 version: 0.14.13 @@ -1245,8 +1245,8 @@ packages: '@vitest/browser-webdriverio': optional: true - vitest-gwt@4.1.3: - resolution: {integrity: sha512-455TLq/jIP6DRol9fknSKxvARTBYpScLQ2kjlbPFZAzMVxxRP7c1a501ChPJprHyQiKfD67dmM0ahVW70nxCEw==} + vitest-gwt@4.1.4: + resolution: {integrity: sha512-YbZ4m0T2kRPZqsHgbkKv+xCM6puWAo6ortRz4F3oPbQV6WRGRKNK+iRtVGi64Jz6NQSE6mGc91fGZNiCQTsoog==} peerDependencies: vitest: 4.1.11 @@ -2233,7 +2233,7 @@ snapshots: - vite - yaml - vitest-gwt@4.1.3(vitest@4.1.11): + vitest-gwt@4.1.4(vitest@4.1.11): dependencies: gwt-runner: 3.0.0 vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-preview@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2))(vitest@4.1.11))(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7f3fe59..f0e94f3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,7 +4,7 @@ catalog: vitest: 4.1.11 "@vitest/coverage-v8": 4.1.11 minimumReleaseAgeExclude: - - vitest-gwt@4.1.3 + - vitest-gwt@4.1.4 overrides: vite@*: "catalog:" vitest@*: "catalog:" diff --git a/src/given/copy_to_workspace.fixtures/hello.txt b/src/given/copy_to_workspace.fixtures/hello.txt new file mode 100644 index 0000000..ce01362 --- /dev/null +++ b/src/given/copy_to_workspace.fixtures/hello.txt @@ -0,0 +1 @@ +hello diff --git a/src/given/copy_to_workspace.spec.ts b/src/given/copy_to_workspace.spec.ts new file mode 100644 index 0000000..d57c1a5 --- /dev/null +++ b/src/given/copy_to_workspace.spec.ts @@ -0,0 +1,326 @@ +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { describe, expect } from "vitest"; +import { test, withAspect } from "vitest-gwt"; + +import { copy_to_workspace, type CopyToWorkspaceOptions } from "./copy_to_workspace.js"; + +type Context = { + sourceRoot: string; + workspace: string; + globs: string[]; + options: CopyToWorkspaceOptions; +}; + +describe("copy_to_workspace", () => { + withAspect(setup_temp_dirs, cleanup_temp_dirs); + + test("preserves folder structure under the copy root", { + given: { + fixture_tree, + glob_fixtures_txt, + }, + when: { + files_are_copied, + }, + then: { + workspace_has_fixtures_suite_a_foo, + workspace_has_fixtures_suite_b_nested_bar, + }, + }); + + test("strips a matching base path prefix", { + given: { + fixture_tree, + glob_fixtures_txt, + base_fixtures_star, + }, + when: { + files_are_copied, + }, + then: { + workspace_has_foo_at_root, + workspace_has_nested_bar, + }, + }); + + test("copies files from multiple globs", { + given: { + fixture_tree, + globs_suite_a_and_suite_b, + }, + when: { + files_are_copied, + }, + then: { + workspace_has_fixtures_suite_a_foo, + workspace_has_fixtures_suite_b_nested_bar, + }, + }); + + test("creates nested destination directories", { + given: { + nested_only_tree, + glob_all_txt, + }, + when: { + files_are_copied, + }, + then: { + workspace_has_deep_nested_file, + }, + }); + + test("throws when a glob matches no files", { + given: { + glob_missing, + }, + when: { + files_are_copied, + }, + then: { + expect_error: empty_glob_error, + }, + }); + + test("throws when a later glob matches no files", { + given: { + fixture_tree, + glob_fixtures_then_missing, + }, + when: { + files_are_copied, + }, + then: { + expect_error: empty_glob_error, + }, + }); + + test("throws when globs is empty", { + given: { + empty_globs, + }, + when: { + files_are_copied, + }, + then: { + expect_error: empty_globs_error, + }, + }); + + test("throws when a file does not match base", { + given: { + fixture_tree, + file_outside_fixtures, + glob_all_txt, + base_fixtures_star, + }, + when: { + files_are_copied, + }, + then: { + expect_error: base_mismatch_error, + }, + }); + + test("throws when two sources map to the same destination", { + given: { + colliding_fixture_tree, + glob_fixtures_txt, + base_fixtures_star, + }, + when: { + files_are_copied, + }, + then: { + expect_error: colliding_destination_error, + }, + }); + + test("copies once when two globs match the same file", { + given: { + fixture_tree, + overlapping_globs_same_files, + }, + when: { + files_are_copied, + }, + then: { + workspace_has_fixtures_suite_a_foo, + workspace_has_fixtures_suite_b_nested_bar, + }, + }); + + test("resolves globs from the current spec directory by default", { + given: { + glob_sibling_fixture, + no_from_override, + }, + when: { + files_are_copied, + }, + then: { + workspace_has_sibling_fixture, + }, + }); + + test("resolves a relative from against the spec directory", { + given: { + glob_hello_txt, + relative_fixtures_from, + }, + when: { + files_are_copied, + }, + then: { + workspace_has_hello_at_root, + }, + }); +}); + +async function setup_temp_dirs(this: Context) { + this.sourceRoot = await mkdtemp(join(tmpdir(), "copy-src-")); + this.workspace = await mkdtemp(join(tmpdir(), "copy-ws-")); + this.options = { from: this.sourceRoot }; +} +async function cleanup_temp_dirs(this: Context) { + await rm(this.sourceRoot, { recursive: true, force: true }); + await rm(this.workspace, { recursive: true, force: true }); +} + +async function fixture_tree(this: Context) { + await writeRelative(this.sourceRoot, "fixtures/suite-a/foo.txt", "foo"); + await writeRelative(this.sourceRoot, "fixtures/suite-b/nested/bar.txt", "bar"); +} + +async function nested_only_tree(this: Context) { + await writeRelative(this.sourceRoot, "deep/nested/file.txt", "nested"); +} + +async function file_outside_fixtures(this: Context) { + await writeRelative(this.sourceRoot, "other/x.txt", "nope"); +} + +async function colliding_fixture_tree(this: Context) { + await writeRelative(this.sourceRoot, "fixtures/suite-a/foo.txt", "a"); + await writeRelative(this.sourceRoot, "fixtures/suite-b/foo.txt", "b"); +} + +function glob_fixtures_txt(this: Context) { + this.globs = ["fixtures/**/*.txt"]; +} + +function globs_suite_a_and_suite_b(this: Context) { + this.globs = ["fixtures/suite-a/*.txt", "fixtures/suite-b/**/*.txt"]; +} + +function glob_all_txt(this: Context) { + this.globs = ["**/*.txt"]; +} + +function glob_missing(this: Context) { + this.globs = ["missing/**/*.txt"]; +} + +function glob_fixtures_then_missing(this: Context) { + this.globs = ["fixtures/**/*.txt", "missing/**/*.txt"]; +} + +function overlapping_globs_same_files(this: Context) { + this.globs = ["fixtures/suite-a/*.txt", "fixtures/**/*.txt"]; +} + +function empty_globs(this: Context) { + this.globs = []; +} + +function glob_sibling_fixture(this: Context) { + this.globs = ["copy_to_workspace.fixtures/hello.txt"]; +} + +function glob_hello_txt(this: Context) { + this.globs = ["hello.txt"]; +} + +function base_fixtures_star(this: Context) { + this.options = { from: this.sourceRoot, base: "fixtures/*" }; +} + +function no_from_override(this: Context) { + this.options = {}; +} + +function relative_fixtures_from(this: Context) { + this.options = { from: "copy_to_workspace.fixtures" }; +} + +async function files_are_copied(this: Context) { + await copy_to_workspace(this.workspace, this.globs, this.options); +} + +async function workspace_has_fixtures_suite_a_foo(this: Context) { + await expectFile(this.workspace, "fixtures/suite-a/foo.txt", "foo"); +} + +async function workspace_has_fixtures_suite_b_nested_bar(this: Context) { + await expectFile(this.workspace, "fixtures/suite-b/nested/bar.txt", "bar"); +} + +async function workspace_has_foo_at_root(this: Context) { + await expectFile(this.workspace, "foo.txt", "foo"); +} + +async function workspace_has_nested_bar(this: Context) { + await expectFile(this.workspace, "nested/bar.txt", "bar"); +} + +async function workspace_has_deep_nested_file(this: Context) { + await expectFile(this.workspace, "deep/nested/file.txt", "nested"); +} + +async function workspace_has_sibling_fixture(this: Context) { + await expectFile(this.workspace, "copy_to_workspace.fixtures/hello.txt", "hello\n"); +} + +async function workspace_has_hello_at_root(this: Context) { + await expectFile(this.workspace, "hello.txt", "hello\n"); +} + +async function empty_glob_error(this: Context, error: Error) { + expect(error.message).toContain("matched no files"); + expect(error.message).toContain("missing/**/*.txt"); + await expectWorkspaceEmpty(this.workspace); +} + +async function empty_globs_error(this: Context, error: Error) { + expect(error.message).toContain("globs must not be empty"); + await expectWorkspaceEmpty(this.workspace); +} + +async function base_mismatch_error(this: Context, error: Error) { + expect(error.message).toContain("does not match base"); + expect(error.message).toContain("fixtures/*"); + await expectWorkspaceEmpty(this.workspace); +} + +async function colliding_destination_error(this: Context, error: Error) { + expect(error.message).toContain("maps to multiple files"); + await expectWorkspaceEmpty(this.workspace); +} + +async function writeRelative(root: string, relativePath: string, contents: string): Promise { + const destination = join(root, relativePath); + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, contents, "utf-8"); +} + +async function expectFile(root: string, relativePath: string, contents: string): Promise { + const actual = await readFile(join(root, relativePath), "utf-8"); + expect(actual).toBe(contents); +} + +async function expectWorkspaceEmpty(workspace: string): Promise { + expect(await readdir(workspace)).toEqual([]); +} diff --git a/src/given/copy_to_workspace.ts b/src/given/copy_to_workspace.ts new file mode 100644 index 0000000..7023cc7 --- /dev/null +++ b/src/given/copy_to_workspace.ts @@ -0,0 +1,268 @@ +import { copyFile, mkdir, readdir, stat } from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +export type CopyToWorkspaceOptions = { + readonly from?: string; + readonly base?: string; +}; + +export async function copy_to_workspace( + workspace: string, + globs: readonly string[], + options?: CopyToWorkspaceOptions, +): Promise { + if (globs.length === 0) { + throw new Error("copy_to_workspace: globs must not be empty"); + } + + const from = await resolveSourceRoot(options); + const resolvedWorkspace = resolve(workspace); + const base = options?.base; + const copies: Array<{ source: string; destination: string }> = []; + const destinationSources = new Map(); + + for (const glob of globs) { + const matches = await expandGlob(from, glob); + if (matches.length === 0) { + throw new Error(`copy_to_workspace: glob "${glob}" matched no files under ${from}`); + } + + for (const relativePath of matches) { + const destinationRelative = base === undefined ? relativePath : stripBase(relativePath, base); + assertSafeRelative(destinationRelative, "destination"); + + const source = resolve(from, relativePath); + const destination = resolve(resolvedWorkspace, destinationRelative); + if (!isInsideRoot(resolvedWorkspace, destination)) { + throw new Error(`copy_to_workspace: destination escapes workspace: ${destinationRelative}`); + } + + const existingSource = destinationSources.get(destinationRelative); + if (existingSource !== undefined) { + if (existingSource === source) { + continue; + } + throw new Error( + `copy_to_workspace: destination "${destinationRelative}" maps to multiple files`, + ); + } + + destinationSources.set(destinationRelative, source); + copies.push({ source, destination }); + } + } + + for (const { source, destination } of copies) { + await mkdir(dirname(destination), { recursive: true }); + await copyFile(source, destination); + } +} + +async function resolveSourceRoot(options: CopyToWorkspaceOptions | undefined): Promise { + const from = options?.from; + if (from !== undefined && isAbsolute(from)) { + return resolve(from); + } + + const specDir = await currentSpecDirectory(); + if (from === undefined) { + return specDir; + } + + return resolve(specDir, from); +} + +async function currentSpecDirectory(): Promise { + const { expect } = await import("vitest"); + const testPath = expect.getState().testPath; + if (testPath === undefined || testPath === "") { + throw new Error( + "copy_to_workspace: cannot resolve the spec directory; pass options.from or call from a Vitest test", + ); + } + + const specFile = testPath.startsWith("file:") ? fileURLToPath(testPath) : testPath; + return dirname(specFile); +} + +async function expandGlob(from: string, glob: string): Promise { + const posixGlob = glob.replaceAll("\\", "/"); + const matcher = globToRegExp(posixGlob); + const prefixSegments = globStaticPrefix(posixGlob); + const walkRoot = prefixSegments.length === 0 ? from : join(from, ...prefixSegments); + if (!isInsideRootOrEqual(from, walkRoot)) { + throw new Error(`copy_to_workspace: glob "${glob}" escapes source root`); + } + + let walkInfo; + try { + walkInfo = await stat(walkRoot); + } catch (error: unknown) { + if (isEnoent(error)) { + return []; + } + throw error; + } + + if (walkInfo.isFile()) { + const relativePath = toPosixRelative(from, walkRoot); + if (!matcher.test(relativePath)) { + return []; + } + return [relativePath]; + } + + if (!walkInfo.isDirectory()) { + return []; + } + + const names = await readdir(walkRoot, { recursive: true }); + const matches: string[] = []; + + for (const name of names) { + const absolutePath = join(walkRoot, name); + if (!isInsideRoot(from, absolutePath)) { + throw new Error(`copy_to_workspace: matched path escapes source root: ${absolutePath}`); + } + + const info = await stat(absolutePath); + const relativePath = toPosixRelative(from, absolutePath); + if (!info.isFile() || !matcher.test(relativePath)) { + continue; + } + + matches.push(relativePath); + } + + return matches; +} + +function globStaticPrefix(glob: string): string[] { + const segments: string[] = []; + for (const segment of glob.split("/")) { + if (segment === "" || segment.includes("*") || segment.includes("?")) { + break; + } + segments.push(segment); + } + return segments; +} + +function toPosixRelative(from: string, absolutePath: string): string { + return relative(from, absolutePath).split(sep).join("/"); +} + +function isInsideRootOrEqual(root: string, absolutePath: string): boolean { + return resolve(root) === resolve(absolutePath) || isInsideRoot(root, absolutePath); +} + +function isInsideRoot(root: string, absolutePath: string): boolean { + const rel = relative(resolve(root), resolve(absolutePath)); + return rel !== "" && !rel.startsWith(`..${sep}`) && rel !== ".." && !rel.startsWith("../"); +} + +function assertSafeRelative(relativePath: string, label: string): void { + const segments = relativePath.split("/"); + if ( + segments.length === 0 || + segments.some((segment) => segment === "" || segment === "." || segment === "..") + ) { + throw new Error(`copy_to_workspace: ${label} is not a safe relative path: ${relativePath}`); + } +} + +function stripBase(relativePath: string, base: string): string { + const pathSegments = relativePath.split("/"); + const baseSegments = base + .replaceAll("\\", "/") + .split("/") + .filter((segment) => segment !== ""); + + if (!matchesBasePrefix(pathSegments, baseSegments)) { + throw new Error(`copy_to_workspace: path "${relativePath}" does not match base "${base}"`); + } + + const destinationSegments = pathSegments.slice(baseSegments.length); + if ( + destinationSegments.length === 0 || + destinationSegments.some((segment) => segment === "" || segment === "..") + ) { + throw new Error( + `copy_to_workspace: stripping base "${base}" from "${relativePath}" left an empty destination`, + ); + } + + return destinationSegments.join("/"); +} + +function matchesBasePrefix( + pathSegments: readonly string[], + baseSegments: readonly string[], +): boolean { + if (pathSegments.length <= baseSegments.length) { + return false; + } + + for (const [index, baseSegment] of baseSegments.entries()) { + const pathSegment = pathSegments[index]; + if (pathSegment === undefined) { + return false; + } + if (baseSegment !== "*" && baseSegment !== pathSegment) { + return false; + } + } + + return true; +} + +function globToRegExp(glob: string): RegExp { + const pattern = glob.replaceAll("\\", "/"); + let regex = "^"; + let index = 0; + + while (index < pattern.length) { + const char = pattern[index]; + if (char === undefined) { + break; + } + + if (char === "*" && pattern[index + 1] === "*") { + if (pattern[index + 2] === "/") { + regex += "(?:.*/)?"; + index += 3; + continue; + } + + regex += ".*"; + index += 2; + continue; + } + + if (char === "*") { + regex += "[^/]*"; + index += 1; + continue; + } + + if (char === "?") { + regex += "[^/]"; + index += 1; + continue; + } + + regex += escapeRegExp(char); + index += 1; + } + + return new RegExp(`${regex}$`); +} + +function escapeRegExp(char: string): string { + return char.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function isEnoent(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"; +} diff --git a/src/index.ts b/src/index.ts index 00f3cdc..c762376 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ export type { } from "./types.js"; export { a_workspace, cleanup_workspace, AGENTS_GWT_TMP_ROOT } from "./given/a_workspace.js"; +export { copy_to_workspace, type CopyToWorkspaceOptions } from "./given/copy_to_workspace.js"; export { the_prompt } from "./given/the_prompt.js"; export { executing_the_agent } from "./when/executing_the_agent.js"; export { agent } from "./given/agent.js";