@xandreed/plugin-workspace 0.0.0-stage → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +4 -2
  3. package/dist/bash-tool.adapter.d.ts +15 -0
  4. package/dist/bash-tool.adapter.js +73 -0
  5. package/dist/bash.adapter.d.ts +81 -0
  6. package/dist/bash.adapter.js +112 -0
  7. package/dist/file-system.adapter.d.ts +24 -0
  8. package/dist/file-system.adapter.js +52 -0
  9. package/dist/image-resizer.adapter.d.ts +5 -0
  10. package/dist/image-resizer.adapter.js +68 -0
  11. package/dist/image-resizer.entity.d.ts +23 -0
  12. package/dist/image-resizer.entity.functions.d.ts +15 -0
  13. package/dist/image-resizer.entity.functions.js +4 -0
  14. package/dist/image-resizer.entity.js +7 -0
  15. package/dist/index.d.ts +14 -0
  16. package/dist/index.js +38 -0
  17. package/dist/local-system.entity.d.ts +47 -0
  18. package/dist/local-system.entity.functions.d.ts +53 -0
  19. package/dist/local-system.entity.functions.js +46 -0
  20. package/dist/local-system.entity.js +12 -0
  21. package/dist/plugin.adapter.d.ts +183 -0
  22. package/dist/plugin.adapter.js +38 -0
  23. package/dist/read-tools.adapter.d.ts +39 -0
  24. package/dist/read-tools.adapter.js +109 -0
  25. package/dist/ripgrep.adapter.d.ts +74 -0
  26. package/dist/ripgrep.adapter.js +56 -0
  27. package/dist/ripgrep.entity.d.ts +63 -0
  28. package/dist/ripgrep.entity.functions.d.ts +87 -0
  29. package/dist/ripgrep.entity.functions.js +62 -0
  30. package/dist/ripgrep.entity.js +10 -0
  31. package/dist/shell.entity.functions.d.ts +2 -0
  32. package/dist/shell.entity.functions.js +1 -0
  33. package/dist/skills/skills-directory.adapter.d.ts +68 -0
  34. package/dist/skills/skills-directory.adapter.js +30 -0
  35. package/dist/skills/skills-directory.entity.d.ts +99 -0
  36. package/dist/skills/skills-directory.entity.functions.d.ts +88 -0
  37. package/dist/skills/skills-directory.entity.functions.js +45 -0
  38. package/dist/skills/skills-directory.entity.js +35 -0
  39. package/dist/text-edit.entity.d.ts +32 -0
  40. package/dist/text-edit.entity.functions.d.ts +32 -0
  41. package/dist/text-edit.entity.functions.js +68 -0
  42. package/dist/text-edit.entity.js +15 -0
  43. package/dist/text-search.entity.d.ts +23 -0
  44. package/dist/text-search.entity.functions.d.ts +58 -0
  45. package/dist/text-search.entity.functions.js +30 -0
  46. package/dist/text-search.entity.js +10 -0
  47. package/dist/text-window.entity.d.ts +46 -0
  48. package/dist/text-window.entity.functions.d.ts +54 -0
  49. package/dist/text-window.entity.functions.js +88 -0
  50. package/dist/text-window.entity.js +21 -0
  51. package/dist/workspace-path.entity.d.ts +43 -0
  52. package/dist/workspace-path.entity.functions.d.ts +90 -0
  53. package/dist/workspace-path.entity.functions.js +88 -0
  54. package/dist/workspace-path.entity.js +9 -0
  55. package/dist/workspace-tools.adapter.d.ts +66 -0
  56. package/dist/workspace-tools.adapter.js +60 -0
  57. package/dist/workspace-tools.entity.d.ts +552 -0
  58. package/dist/workspace-tools.entity.functions.d.ts +126 -0
  59. package/dist/workspace-tools.entity.functions.js +180 -0
  60. package/dist/workspace-tools.entity.js +154 -0
  61. package/dist/write-tools.adapter.d.ts +12 -0
  62. package/dist/write-tools.adapter.js +79 -0
  63. package/package.json +30 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Xand Reed
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @xandreed/plugin-workspace
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Efferent composable agent framework, 0.15.0. Node 24 or Bun.
4
+
5
+ Documentation: https://github.com/xandreeddev/efferent
@@ -0,0 +1,15 @@
1
+ import type { RegisteredTool } from "@xandreed/core";
2
+ import type { WorkspaceToolContext } from "./read-tools.adapter.js";
3
+ /**
4
+ * `bash`: run a command in the workspace root through the `Bash` service of
5
+ * the call. Output is published as it comes (`tool.progress`: gathered for
6
+ * `progressMs`, then one event per run of output from one stream), and
7
+ * folded into a tail the model is shown: the last `maxLines` lines within
8
+ * `maxBytes`. Once the output is longer than that, it goes to a file under
9
+ * `spillDir` (named by time and call), whose path the result gives, up to
10
+ * `maxFileBytes` (what `read` opens; past that nothing more is saved, and
11
+ * the result says `fullOutputCut`); the newest `spillKeep` files are kept.
12
+ * A non-zero exit, a signal and a timeout are results; only a command that
13
+ * could not run at all is a failure.
14
+ */
15
+ export declare const bashTool: (context: WorkspaceToolContext) => RegisteredTool;
@@ -0,0 +1,73 @@
1
+ import { Tool } from "effect/ai";
2
+ import { Array as Arr, Clock, Effect, Option, Order, Random, Stream } from "effect";
3
+ import { Bash, BashEvent, CurrentToolInvocation, defineTool, Failure, publishToolProgress, RunContext } from "@xandreed/core";
4
+ import { clipHead, tailText, utf8Length } from "./text-window.entity.functions.js";
5
+ import { openWorkspace } from "./workspace-path.entity.functions.js";
6
+ import { BashParams, BashToolResult, WORKSPACE_PERMISSIONS } from "./workspace-tools.entity.js";
7
+ import { bashEnvironment, compactBash, renderBash, toolDescriptions } from "./workspace-tools.entity.functions.js";
8
+ import { failure, WORKSPACE_SERVICES } from "./read-tools.adapter.js";
9
+ const BashTool = (description) => Tool.make("bash", { description, parameters: BashParams, success: BashToolResult, failure: Failure, failureMode: "return" }), runsOf = (chunks) => chunks.reduce((runs, chunk) => {
10
+ const last = runs.at(-1);
11
+ if (last !== void 0 && last.stream === chunk.stream)
12
+ runs.splice(-1, 1, { stream: chunk.stream, text: last.text + chunk.text });
13
+ else
14
+ runs.push({ stream: chunk.stream, text: chunk.text });
15
+ return runs;
16
+ }, []), prune = (access, dir, keep) => access.fs.list(dir).pipe(Effect.map((entries) => Arr.sort(entries.filter((entry) => entry.kind === "file" && /^bash-.*\.log$/.test(entry.name)).map((entry) => entry.name), Order.String)), Effect.flatMap((names) => Effect.forEach(names.slice(0, Math.max(0, names.length - keep)), (name) => access.fs.remove(access.path.join(dir, name)), { concurrency: 1, discard: !0 })), Effect.ignore({ log: "Warn", message: `the older spill files in ${dir} were not pruned` }));
17
+ export const bashTool = (context) => defineTool({
18
+ tool: BashTool(toolDescriptions(context.config).bash),
19
+ requires: [...WORKSPACE_SERVICES, Bash],
20
+ handler: (params) => Effect.gen(function* () {
21
+ const { config } = context, access = yield* openWorkspace(context.path), bash = yield* Bash, run = yield* Effect.serviceOption(RunContext), invocation = yield* Effect.service(CurrentToolInvocation), timeoutMs = Math.min(params.timeout === void 0 ? config.bashTimeoutMs : params.timeout * 1000, config.bashMaxTimeoutMs), options = {
22
+ cwd: access.root,
23
+ ...bashEnvironment(config, Option.map(run, (current) => ({ sessionId: String(current.session.id), runId: current.runId, workspace: access.root }))),
24
+ timeoutMs,
25
+ ...params.stdin === void 0 ? {} : { stdin: params.stdin }
26
+ }, spillDir = Option.map(Option.fromNullishOr(config.spillDir), (dir) => access.path.join(access.root, dir)), started = yield* Clock.currentTimeMillis, tag = yield* Option.match(invocation, {
27
+ onSome: (call) => Effect.succeed(call.invocationId.replace(/[^A-Za-z0-9._-]/g, "_")),
28
+ onNone: () => Random.nextIntBetween(0, 2147483647).pipe(Effect.map((value) => value.toString(36)))
29
+ }), spillName = `bash-${String(started).padStart(15, "0")}-${tag}.log`, fitting = (bytes, text) => {
30
+ const piece = clipHead(text, Math.max(0, config.maxFileBytes - bytes));
31
+ return { piece, bytes: bytes + utf8Length(piece), cut: piece.length < text.length };
32
+ }, startSpill = (text) => Option.match(spillDir, {
33
+ onNone: () => Effect.succeed(Option.none()),
34
+ onSome: (dir) => {
35
+ const file = access.path.join(dir, spillName), first = fitting(0, text);
36
+ return access.fs.write(file, first.piece, { createParents: !0 }).pipe(Effect.andThen(prune(access, dir, config.spillKeep)), Effect.as(Option.some({ file, bytes: first.bytes, cut: first.cut })), Effect.catch((error) => Effect.logWarning(`bash: the command's whole output could not be saved to ${file}: ${error.message}`).pipe(Effect.as(Option.none()))));
37
+ }
38
+ }), addToSpill = (spill, text) => {
39
+ if (spill.cut || text.length === 0)
40
+ return Effect.succeed(spill);
41
+ const next = fitting(spill.bytes, text);
42
+ return (next.piece.length === 0 ? Effect.void : access.fs.append(spill.file, next.piece).pipe(Effect.catch((error) => Effect.logWarning(`bash: could not add to ${spill.file}: ${error.message}`)))).pipe(Effect.as({ ...spill, bytes: next.bytes, cut: next.cut }));
43
+ }, over = (state) => state.bytes - state.crlf - 1 > config.maxBytes || state.breaks > config.maxLines, step = (state, group) => Effect.gen(function* () {
44
+ const chunks = group.filter(BashEvent.guards.BashChunk);
45
+ yield* Effect.forEach(runsOf(chunks), (current) => publishToolProgress(current.stream, current.text), { concurrency: 1, discard: !0 });
46
+ const text = chunks.map((chunk) => chunk.text).join(""), bytes = state.bytes + utf8Length(text), breaks = state.breaks + (text.split(`
47
+ `).length - 1), crlf = state.crlf + (text.split(`\r
48
+ `).length - 1) + (state.tail.endsWith("\r") && text.startsWith(`
49
+ `) ? 1 : 0), exit = Option.orElse(Option.fromNullishOr(group.find(BashEvent.guards.BashExit)), () => state.exit), base = { tail: (state.tail + text).slice(-(config.maxBytes + 2)), bytes, breaks, crlf, open: text.length === 0 ? state.open : !text.endsWith(`
50
+ `), exit };
51
+ if (Option.isSome(state.spill))
52
+ return { ...base, whole: Option.none(), spill: Option.some(yield* addToSpill(state.spill.value, text)) };
53
+ const whole = Option.map(state.whole, (sofar) => sofar + text);
54
+ if (!over(base) || Option.isNone(whole))
55
+ return { ...base, whole, spill: Option.none() };
56
+ return { ...base, whole: Option.none(), spill: yield* startSpill(whole.value) };
57
+ }), gathered = yield* bash.stream(params.command, options).pipe(Stream.groupedWithin(4096, `${config.progressMs} millis`), Stream.runFoldEffect(() => ({ tail: "", whole: Option.some(""), bytes: 0, breaks: 0, crlf: 0, open: !1, spill: Option.none(), exit: Option.none() }), step), Effect.mapError((error) => failure(error.reason, error.message))), shown = tailText(Option.getOrElse(gathered.whole, () => gathered.tail), config.maxLines, config.maxBytes), truncated = Option.isNone(gathered.whole) || shown.truncated, spill = Option.isSome(gathered.spill) || !truncated ? gathered.spill : yield* Option.match(gathered.whole, { onNone: () => Effect.succeed(Option.none()), onSome: startSpill }), ended = yield* Clock.currentTimeMillis;
58
+ return {
59
+ command: params.command,
60
+ output: shown.text,
61
+ exitCode: Option.getOrNull(Option.flatMap(gathered.exit, (exit) => exit.exitCode)),
62
+ signal: Option.getOrNull(Option.flatMap(gathered.exit, (exit) => exit.signal)),
63
+ termination: Option.match(gathered.exit, { onNone: () => "interrupted", onSome: (exit) => exit.termination }),
64
+ truncated,
65
+ totalLines: gathered.breaks + (gathered.open ? 1 : 0),
66
+ fullOutput: Option.getOrNull(Option.map(spill, (saved) => access.path.relative(access.root, saved.file))),
67
+ fullOutputCut: Option.exists(spill, (saved) => saved.cut),
68
+ durationMs: Option.match(gathered.exit, { onNone: () => ended - started, onSome: (exit) => exit.durationMs })
69
+ };
70
+ }),
71
+ view: { version: "2", render: renderBash, compact: compactBash },
72
+ annotations: { permissions: [WORKSPACE_PERMISSIONS.exec], pollable: !0 }
73
+ });
@@ -0,0 +1,81 @@
1
+ import type { Readable } from "node:stream";
2
+ import { Cause, Effect, Layer, Option, Queue, Stream } from "effect";
3
+ import type { Context, Scope } from "effect";
4
+ import { Bash, BashError } from "@xandreed/core";
5
+ import type { BashChunk, BashEvent, BashExit, BashOptions } from "@xandreed/core";
6
+ import type { BashLiveOptions } from "./local-system.entity.js";
7
+ type BashShape = Context.Service.Shape<typeof Bash>;
8
+ /**
9
+ * The most events a command's stream holds before the command is made to
10
+ * wait: a pipe read is at most 64 KB on Node, so about 1 MB of output.
11
+ */
12
+ export declare const OUTPUT_BUFFER = 16;
13
+ /** How a command's output pipes feed its stream (`relayOutput`). */
14
+ export interface OutputRelay {
15
+ /** Read `pipe` as `name`: UTF-8, a character split between reads arriving whole; an error on it fails the stream. */
16
+ readonly attach: (name: BashChunk["stream"], pipe: Readable) => void;
17
+ /** Stop pausing the pipes: what is left of the output is read at once. */
18
+ readonly flow: () => void;
19
+ /** The decoders' last characters, then `exit`, after everything before them; the stream ends after the exit. */
20
+ readonly finish: (exit: BashExit) => void;
21
+ }
22
+ /**
23
+ * The bridge from a command's output pipes to its stream's buffer (`queue`,
24
+ * which holds at most `OUTPUT_BUFFER` events). An event goes straight in
25
+ * while there is room and nothing waits before it. Otherwise it waits, in
26
+ * arrival order, and the pipe it came from is paused, so the command blocks
27
+ * on a full pipe instead of its output piling up in memory; a fiber of the
28
+ * stream's scope moves waiting events in as the reader takes them, and
29
+ * resumes their pipes. `flow` ends the pausing, for once the command has
30
+ * exited (what is left then is the pipes' content, and whatever a
31
+ * background process writes in `exitGraceMs`). An error on a pipe fails the
32
+ * stream with `SpawnFailed`.
33
+ */
34
+ export declare const relayOutput: (queue: Queue.Queue<{
35
+ readonly _tag: "BashChunk";
36
+ readonly stream: "stdout" | "stderr";
37
+ readonly text: string;
38
+ } | {
39
+ readonly _tag: "BashExit";
40
+ readonly termination: "interrupted" | "exit" | "signal" | "timeout";
41
+ readonly exitCode: Option.Option<number>;
42
+ readonly signal: Option.Option<string>;
43
+ readonly durationMs: number;
44
+ }, BashError | Cause.Done<void>>) => Effect.Effect<OutputRelay, never, Scope.Scope>;
45
+ /**
46
+ * Commands on the local machine through `node:child_process`, for Node and
47
+ * Bun: `<shell> -c <command>`, detached into a process group of its own, in
48
+ * an environment `resolveBashEnv` builds from `parentEnv`. The shell is
49
+ * resolved to an absolute path once, here, from the parent `PATH` (so a
50
+ * command whose own environment has no `PATH` still runs); none fails with
51
+ * `ShellMissing`. Each command checks its `cwd` first (`CwdMissing`); its
52
+ * stdin is written and closed (a command that never reads it is fine), its
53
+ * stdout and stderr decoded as UTF-8 per stream, a character split between
54
+ * reads arriving whole. While the command runs, its output is read only as
55
+ * fast as the stream is (`relayOutput`): on Node a reader that falls behind
56
+ * holds the command back on a full pipe, so output is never gathered
57
+ * without bound (Bun reads a child's pipes ahead into its own memory, the
58
+ * reader notwithstanding). A timeout, or a caller that stops reading before
59
+ * the end, kills the whole group; with `killBackground` (the default) so
60
+ * does the command's return. The stream ends once the command has exited
61
+ * and its output has closed, or `exitGraceMs` after the exit if something
62
+ * still holds the output open; the adapter then closes its end of the
63
+ * output pipes, so a background process kept with `killBackground: false`
64
+ * that still writes to them gets `EPIPE` or dies of `SIGPIPE` unless it was
65
+ * started with its own output (`cmd > log 2>&1 &`). POSIX only (Linux and
66
+ * macOS): it relies on process groups. Passes `bashConformance` on Bun and
67
+ * Node (on Bun without the hold-back half of `backpressure`).
68
+ */
69
+ export declare const makeBash: (options?: BashLiveOptions | undefined) => Effect.Effect<{
70
+ readonly stream: (command: string, options: BashOptions) => Stream.Stream<BashEvent, BashError>;
71
+ readonly exec: (command: string, options: BashOptions) => Effect.Effect<import("@xandreed/core").BashResult, BashError>;
72
+ }, BashError, never>;
73
+ /** Whether a `Bash` service is one `makeBash` built, so its commands see the same files as `localFileSystem`. */
74
+ export declare const runsOnThisMachine: (bash: BashShape) => boolean;
75
+ /**
76
+ * The local `Bash` (`makeBash`) as a layer; building it fails with `ShellMissing` when the shell is not installed.
77
+ *
78
+ * @public
79
+ */
80
+ export declare const BashLive: (options?: BashLiveOptions) => Layer.Layer<Bash, BashError>;
81
+ export {};
@@ -0,0 +1,112 @@
1
+ import { spawn } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import { access, stat } from "node:fs/promises";
4
+ import { Cause, Clock, Deferred, Effect, Layer, MutableRef, Option, Queue, Result, Stream } from "effect";
5
+ import { Bash, BashError, collectBashResult, resolveBashEnv } from "@xandreed/core";
6
+ import { bashExitOf, errorMessageOf, executableCandidates, isClosedInput } from "./local-system.entity.functions.js";
7
+ import { localSystemDefaults } from "./local-system.entity.js";
8
+ const isExecutableFile = (path) => Effect.tryPromise(() => access(path, constants.X_OK)).pipe(Effect.andThen(Effect.tryPromise(() => stat(path))), Effect.map((info) => info.isFile()), Effect.tapError(() => Effect.logDebug(`${path} is not an executable file`)), Effect.orElseSucceed(() => !1)), resolveShell = (shell, path) => Effect.findFirst(executableCandidates(shell, path), isExecutableFile).pipe(Effect.flatMap(Option.match({
9
+ onNone: () => Effect.fail(new BashError({
10
+ reason: "ShellMissing",
11
+ message: `no executable ${shell} ${shell.startsWith("/") ? "at that path" : `on the PATH (${Option.getOrElse(path, () => "unset")})`}`
12
+ })),
13
+ onSome: Effect.succeed
14
+ }))), checkCwd = (cwd) => {
15
+ const missing = () => new BashError({ reason: "CwdMissing", message: `cwd is not an existing absolute directory: ${cwd}` });
16
+ return cwd.startsWith("/") ? Effect.tryPromise({ try: () => stat(cwd), catch: missing }).pipe(Effect.flatMap((info) => info.isDirectory() ? Effect.void : Effect.fail(missing()))) : Effect.fail(missing());
17
+ }, killGroup = (child, exited) => Option.match(Option.fromNullishOr(child.pid), {
18
+ onNone: () => {
19
+ return;
20
+ },
21
+ onSome: (pid) => {
22
+ const group = Result.try(() => process.kill(-pid, "SIGKILL"));
23
+ if (Result.isFailure(group) && !exited)
24
+ Result.try(() => process.kill(pid, "SIGKILL"));
25
+ }
26
+ });
27
+ export const OUTPUT_BUFFER = 16, relayOutput = Effect.fnUntraced(function* (queue) {
28
+ const waiting = yield* Queue.unbounded(), behind = MutableRef.make(0), flowing = MutableRef.make(!1), pipes = MutableRef.make([]), decoders = { stdout: new TextDecoder, stderr: new TextDecoder }, wait = (event, pipe) => {
29
+ if (Option.isSome(pipe) && !MutableRef.get(flowing))
30
+ pipe.value.pause();
31
+ MutableRef.increment(behind);
32
+ Queue.offerUnsafe(waiting, { event, pipe });
33
+ }, deliver = (event, pipe) => {
34
+ if (MutableRef.get(behind) > 0 || !Queue.offerUnsafe(queue, event))
35
+ wait(event, Option.some(pipe));
36
+ }, chunk = (name, text) => text.length === 0 ? Option.none() : Option.some({ _tag: "BashChunk", stream: name, text });
37
+ yield* Queue.take(waiting).pipe(Effect.flatMap(({ event, pipe }) => Queue.offer(queue, event).pipe(Effect.andThen(Effect.sync(() => {
38
+ MutableRef.decrement(behind);
39
+ Option.map(pipe, (readable) => readable.resume());
40
+ if (event._tag === "BashExit")
41
+ Queue.endUnsafe(queue);
42
+ })))), Effect.forever, Effect.forkScoped);
43
+ return {
44
+ attach: (name, pipe) => {
45
+ MutableRef.update(pipes, (all) => [...all, pipe]);
46
+ pipe.on("data", (bytes) => Option.map(chunk(name, decoders[name].decode(bytes, { stream: !0 })), (event) => deliver(event, pipe)));
47
+ pipe.on("error", (error) => Queue.failCauseUnsafe(queue, Cause.fail(new BashError({ reason: "SpawnFailed", message: `could not read the command's ${name}: ${errorMessageOf(error)}` }))));
48
+ },
49
+ flow: () => {
50
+ MutableRef.set(flowing, !0);
51
+ MutableRef.get(pipes).forEach((pipe) => pipe.resume());
52
+ },
53
+ finish: (exit) => {
54
+ Option.map(chunk("stdout", decoders.stdout.decode()), (event) => wait(event, Option.none()));
55
+ Option.map(chunk("stderr", decoders.stderr.decode()), (event) => wait(event, Option.none()));
56
+ wait(exit, Option.none());
57
+ }
58
+ };
59
+ }), makeBash = Effect.fnUntraced(function* (options = {}) {
60
+ const { shell, killBackground, exitGraceMs } = { ...localSystemDefaults, ...options }, parentEnv = () => options.parentEnv ?? process.env, shellPath = yield* resolveShell(shell, Option.fromNullishOr(parentEnv().PATH)), stream = (command, run) => Stream.callback((queue) => Effect.gen(function* () {
61
+ yield* checkCwd(run.cwd);
62
+ const clock = yield* Effect.clockWith((current) => Effect.succeed(current)), exited = yield* Deferred.make(), closed = yield* Deferred.make(), finished = MutableRef.make(!1), output = yield* relayOutput(queue), started = yield* Clock.currentTimeMillis, child = yield* Effect.acquireRelease(Effect.try({
63
+ try: () => {
64
+ const spawned = spawn(shellPath, ["-c", command], {
65
+ cwd: run.cwd,
66
+ env: { ...resolveBashEnv(parentEnv(), run) },
67
+ detached: !0,
68
+ stdio: [run.stdin === void 0 ? "ignore" : "pipe", "pipe", "pipe"]
69
+ });
70
+ if (spawned.stdout !== null)
71
+ output.attach("stdout", spawned.stdout);
72
+ if (spawned.stderr !== null)
73
+ output.attach("stderr", spawned.stderr);
74
+ spawned.once("error", (error) => Deferred.doneUnsafe(exited, Effect.fail(new BashError({ reason: "SpawnFailed", message: errorMessageOf(error) }))));
75
+ spawned.once("exit", (code, signal) => Deferred.doneUnsafe(exited, Effect.succeed({ code, signal, at: clock.currentTimeMillisUnsafe() })));
76
+ spawned.once("close", () => Deferred.doneUnsafe(closed, Effect.void));
77
+ spawned.stdin?.on("error", (error) => isClosedInput(error) ? !1 : Queue.failCauseUnsafe(queue, Cause.fail(new BashError({ reason: "SpawnFailed", message: `could not write the command's stdin: ${errorMessageOf(error)}` }))));
78
+ spawned.stdin?.end(run.stdin);
79
+ return spawned;
80
+ },
81
+ catch: (error) => new BashError({ reason: "SpawnFailed", message: errorMessageOf(error) })
82
+ }), (spawned) => Effect.sync(() => {
83
+ if (!MutableRef.get(finished))
84
+ killGroup(spawned, Deferred.isDoneUnsafe(exited));
85
+ spawned.stdout?.destroy();
86
+ spawned.stderr?.destroy();
87
+ spawned.stdin?.destroy();
88
+ })), awaited = Deferred.await(exited).pipe(Effect.map((end) => ({ end, timedOut: !1 }))), { end, timedOut } = yield* run.timeoutMs === void 0 ? awaited : Deferred.await(exited).pipe(Effect.timeoutOption(run.timeoutMs), Effect.flatMap(Option.match({
89
+ onSome: (end) => Effect.succeed({ end, timedOut: !1 }),
90
+ onNone: () => Effect.sync(() => killGroup(child, Deferred.isDoneUnsafe(exited))).pipe(Effect.andThen(awaited), Effect.map(({ end }) => ({ end, timedOut: !0 })))
91
+ })));
92
+ if (killBackground)
93
+ killGroup(child, !0);
94
+ output.flow();
95
+ const drained = yield* Deferred.await(closed).pipe(Effect.timeoutOption(exitGraceMs));
96
+ if (Option.isNone(drained)) {
97
+ child.stdout?.destroy();
98
+ child.stderr?.destroy();
99
+ }
100
+ MutableRef.set(finished, !0);
101
+ output.finish(bashExitOf({ code: end.code, signal: end.signal, timedOut, durationMs: end.at - started }));
102
+ }), { bufferSize: OUTPUT_BUFFER }), local = Bash.of({
103
+ stream,
104
+ exec: Effect.fn("bash.exec")(function* (command, run) {
105
+ return yield* collectBashResult(stream(command, run));
106
+ })
107
+ });
108
+ localShells.add(local);
109
+ return local;
110
+ });
111
+ const localShells = new WeakSet;
112
+ export const runsOnThisMachine = (bash) => localShells.has(bash), BashLive = (options = {}) => Layer.effect(Bash, makeBash(options));
@@ -0,0 +1,24 @@
1
+ import { Layer } from "effect";
2
+ import type { Context } from "effect";
3
+ import { FileSystem } from "@xandreed/core";
4
+ type Fs = Context.Service.Shape<typeof FileSystem>;
5
+ /**
6
+ * The machine's own file system through `node:fs/promises`, for Node and
7
+ * Bun. Paths must be absolute (a relative one fails with `Invalid`); every
8
+ * failure's `reason` comes from the error code (`fsReasonOf`). `glob` is
9
+ * `walkGlob` over `list`, so it matches what every other backend matches
10
+ * and never enters a linked directory. `remove` resolves the parent first
11
+ * and acts on the last name only, so it never follows that name and never
12
+ * removes the root (nor a path ending in `.` or `..`); `rename` names the
13
+ * side a failure came from. `stat` reports the permission bits as `mode`.
14
+ * POSIX only (Linux and macOS): a path must start with `/`. Passes
15
+ * `fileSystemConformance` on Bun and Node.
16
+ */
17
+ export declare const localFileSystem: Fs;
18
+ /**
19
+ * The local `FileSystem` (`localFileSystem`) as a layer.
20
+ *
21
+ * @public
22
+ */
23
+ export declare const FileSystemLive: Layer.Layer<FileSystem>;
24
+ export {};
@@ -0,0 +1,52 @@
1
+ import { appendFile, lstat, mkdir, open, readdir, readFile, realpath, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { Effect, Layer, Option, Result } from "effect";
4
+ import { FileSystem, FsError, walkGlob } from "@xandreed/core";
5
+ import { errorMessageOf, fsReasonOf, pathIn, removalPlace } from "./local-system.entity.functions.js";
6
+ const refused = (path, reason, message) => new FsError({ path, reason, message }), attempt = (path, run) => path.startsWith("/") ? Effect.tryPromise({ try: run, catch: (error) => refused(path, fsReasonOf(error), errorMessageOf(error)) }) : Effect.fail(refused(path, "Invalid", `paths must be absolute: ${path}`)), kindOf = (entry) => entry.isSymbolicLink() ? "symlink" : entry.isFile() ? "file" : entry.isDirectory() ? "directory" : "other", byName = (left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0, bytesOf = (buffer) => new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength), parentsOf = (path) => attempt(path, () => mkdir(dirname(path), { recursive: !0 })).pipe(Effect.asVoid), chunkSize = 65536, joined = (chunks) => {
7
+ const whole = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0));
8
+ chunks.reduce((at, chunk) => {
9
+ whole.set(chunk, at);
10
+ return at + chunk.length;
11
+ }, 0);
12
+ return whole;
13
+ }, readRange = (path, offset, length) => Effect.acquireUseRelease(attempt(path, () => open(path, "r")), (handle) => attempt(path, () => handle.stat()).pipe(Effect.flatMap((info) => {
14
+ if (info.isDirectory())
15
+ return Effect.fail(refused(path, "IsDirectory", `is a directory: ${path}`));
16
+ const step = Math.max(chunkSize, info.size - offset), gather = (position, remaining, chunks) => {
17
+ const size = Math.min(remaining, step);
18
+ return size <= 0 ? Effect.succeed(chunks) : attempt(path, () => handle.read(new Uint8Array(size), 0, size, position)).pipe(Effect.flatMap(({ bytesRead, buffer }) => bytesRead === 0 ? Effect.succeed(chunks) : gather(position === null ? null : position + bytesRead, remaining - bytesRead, [...chunks, buffer.subarray(0, bytesRead)])));
19
+ };
20
+ return gather(offset === 0 ? null : offset, Option.getOrElse(length, () => Number.POSITIVE_INFINITY), []).pipe(Effect.map(joined));
21
+ })), (handle) => Effect.tryPromise(() => handle.close()).pipe(Effect.ignore({ log: "Warn", message: `the read handle of ${path} did not close` }))), readBytes = (path, range) => {
22
+ const offset = range?.offset ?? 0, length = Option.fromNullishOr(range?.length);
23
+ if (!(Number.isInteger(offset) && offset >= 0 && Option.match(length, { onNone: () => !0, onSome: (count) => Number.isInteger(count) && count >= 0 })))
24
+ return Effect.fail(refused(path, "Invalid", `offset and length must be whole numbers of at least 0, not ${JSON.stringify(range)}`));
25
+ return offset === 0 && Option.isNone(length) ? attempt(path, () => readFile(path)).pipe(Effect.map(bytesOf)) : readRange(path, offset, length);
26
+ }, list = (dir) => attempt(dir, () => readdir(dir, { withFileTypes: !0 })).pipe(Effect.map((entries) => entries.map((entry) => ({ name: entry.name, kind: kindOf(entry) })).toSorted(byName))), remove = (path, options) => Result.match(removalPlace(path), {
27
+ onFailure: (message) => Effect.fail(refused(path, "Invalid", message)),
28
+ onSuccess: ({ parent, name }) => attempt(path, () => realpath(parent)).pipe(Effect.map((dir) => pathIn(dir, name)), Effect.flatMap((target) => attempt(path, () => lstat(target)).pipe(Effect.flatMap((info) => !info.isDirectory() ? attempt(path, () => unlink(target)) : options?.recursive === !0 ? attempt(path, () => rm(target, { recursive: !0, force: !0 })) : attempt(path, () => rmdir(target)).pipe(Effect.mapError((error) => error.reason === "Exists" ? refused(path, "NotEmpty", error.message) : error))))), Effect.catchIf((error) => error.reason === "NotFound", () => Effect.void))
29
+ }), destinationOnly = new Set(["IsDirectory", "NotEmpty", "Exists"]), eitherSide = new Set(["NotFound", "NotDirectory", "Invalid"]), move = (from, to) => !to.startsWith("/") ? Effect.fail(refused(to, "Invalid", `paths must be absolute: ${to}`)) : attempt(from, () => rename(from, to)).pipe(Effect.catch((error) => {
30
+ const atTo = new FsError({ path: to, reason: error.reason === "Exists" ? "NotEmpty" : error.reason, message: error.message });
31
+ return destinationOnly.has(error.reason) ? Effect.fail(atTo) : eitherSide.has(error.reason) ? Effect.tryPromise(() => lstat(from)).pipe(Effect.match({ onFailure: () => error, onSuccess: () => atTo }), Effect.flatMap(Effect.fail)) : Effect.fail(error);
32
+ })), statOf = (path, options) => attempt(path, () => options?.followSymlinks === !1 ? lstat(path) : stat(path)).pipe(Effect.map((info) => ({
33
+ path,
34
+ kind: kindOf(info),
35
+ size: info.size,
36
+ mtimeMs: info.mtimeMs,
37
+ mode: Option.some(info.mode & 4095)
38
+ })));
39
+ export const localFileSystem = FileSystem.of({
40
+ read: (path) => attempt(path, () => readFile(path, "utf8")),
41
+ readBytes,
42
+ write: (path, content, options) => (options?.createParents === !0 ? parentsOf(path) : Effect.void).pipe(Effect.andThen(attempt(path, () => writeFile(path, content)))),
43
+ append: (path, content, options) => (options?.createParents === !0 ? parentsOf(path) : Effect.void).pipe(Effect.andThen(attempt(path, () => appendFile(path, content)))),
44
+ rename: move,
45
+ stat: statOf,
46
+ exists: (path) => attempt(path, () => stat(path)).pipe(Effect.as(!0), Effect.catchIf((error) => error.reason === "NotFound" || error.reason === "NotDirectory", () => Effect.succeed(!1))),
47
+ list,
48
+ glob: (pattern, options) => walkGlob(list, pattern, options),
49
+ mkdir: (dir, options) => attempt(dir, () => mkdir(dir, { recursive: options?.recursive === !0 })).pipe(Effect.asVoid),
50
+ realPath: (path) => attempt(path, () => realpath(path)),
51
+ remove
52
+ }), FileSystemLive = Layer.succeed(FileSystem, localFileSystem);
@@ -0,0 +1,5 @@
1
+ import { Layer } from "effect";
2
+ import { Bash, FileSystem, ImageResizer } from "@xandreed/core";
3
+ import { ImageResizerConfig } from "./image-resizer.entity.js";
4
+ /** Fit images through an optional host CLI; FileSystem and Bash must share a file tree. @public */
5
+ export declare const ImageResizerCliLive: (options?: Partial<ImageResizerConfig>) => Layer.Layer<ImageResizer, import("@xandreed/core").HarnessError, FileSystem | Bash>;
@@ -0,0 +1,68 @@
1
+ import { Effect, Layer, Option, Path, Schema } from "effect";
2
+ import { Bash, FileSystem, harnessError, ImageFitTarget, ImageInput, ImageResizeError, ImageResizer, imageSizeOf, mediaFromBytes, mergeOptions, sniffImageType } from "@xandreed/core";
3
+ import { imageOpacityCommand, imageResizeCommand, imageToolProbe, privateDirectoryCommand, privateInputCommand, privately } from "./image-resizer.entity.functions.js";
4
+ import { ImageResizerConfig, imageResizerDefaults, ImageResizeTool } from "./image-resizer.entity.js";
5
+ import { bashEnvironment } from "./workspace-tools.entity.functions.js";
6
+ import { workspaceToolsDefaults } from "./workspace-tools.entity.js";
7
+ const resizeError = (reason, message, cause) => new ImageResizeError({ reason, message, ...cause === void 0 ? {} : { cause } }), succeeded = (result) => result.termination === "exit" && Option.contains(result.exitCode, 0), makeResizer = Effect.fnUntraced(function* (options) {
8
+ const config = yield* Schema.decodeUnknownEffect(ImageResizerConfig)(mergeOptions(ImageResizerConfig, imageResizerDefaults, options), { onExcessProperty: "error" }).pipe(Effect.mapError((cause) => harnessError("config.options", "the image resizer options do not decode", cause))), fs = yield* FileSystem, bash = yield* Bash, path = yield* Path.Path.pipe(Effect.provide(Path.layer)), environment = bashEnvironment(workspaceToolsDefaults, Option.none()), candidates = config.tool === "auto" ? ImageResizeTool.literals : [config.tool], available = yield* Effect.findFirst(candidates, (tool) => bash.exec(imageToolProbe(tool), { cwd: "/", timeoutMs: Math.min(config.timeoutMs, 5000), ...environment }).pipe(Effect.map(succeeded), Effect.catch((error) => Effect.logDebug("an image conversion tool could not be probed", error).pipe(Effect.as(!1)))));
9
+ if (Option.isNone(available))
10
+ return yield* Effect.fail(harnessError("images.unavailable", "no image conversion tool is available; install ImageMagick, libvips or sips, or provide an ImageResizer"));
11
+ const tool = available.value, temporary = config.tempDir === null ? (yield* bash.exec(`printf '%s' "\${TMPDIR:-/tmp}"`, { cwd: "/", timeoutMs: Math.min(config.timeoutMs, 5000), ...environment }).pipe(Effect.mapError((cause) => harnessError("config.options", "the image resizer could not read TMPDIR; set tempDir", cause)))).stdout : config.tempDir;
12
+ if (!temporary.startsWith("/") || temporary.includes("\x00"))
13
+ return yield* Effect.fail(harnessError("config.options", "the image resizer tempDir must be an absolute POSIX directory"));
14
+ const fit = Effect.fn("images.fit")(function* (image, target) {
15
+ const input = yield* Schema.decodeUnknownEffect(ImageInput)(image).pipe(Effect.mapError((cause) => resizeError("Unsupported", "the image input does not decode", cause))), bounds = yield* Schema.decodeUnknownEffect(ImageFitTarget)(target).pipe(Effect.mapError((cause) => resizeError("Failed", "the image fit target does not decode", cause))), type = sniffImageType(input.bytes);
16
+ if (!Option.contains(type, input.mediaType))
17
+ return yield* Effect.fail(resizeError("Unsupported", "the bytes are not an image of the declared type"));
18
+ const size = imageSizeOf(input.bytes, input.mediaType);
19
+ if (Option.isNone(size))
20
+ return yield* Effect.fail(resizeError("Unsupported", "the image header does not give its dimensions"));
21
+ const original = size.value;
22
+ if (input.bytes.length <= bounds.maxBytes && Math.max(original.width, original.height) <= bounds.maxSide)
23
+ return input.bytes;
24
+ if (original.width * original.height > config.maxSourcePixels)
25
+ return yield* Effect.fail(resizeError("Unsupported", `the source image declares ${original.width}x${original.height} pixels, more than the resizer's maxSourcePixels (${config.maxSourcePixels})`));
26
+ return yield* Effect.scoped(Effect.gen(function* () {
27
+ const nonce = yield* Effect.sync(() => crypto.randomUUID()), directory = path.join(temporary, `efferent-img-${nonce}`);
28
+ yield* Effect.addFinalizer(() => fs.remove(directory, { recursive: !0 }).pipe(Effect.catch((error) => error.reason === "NotFound" ? Effect.void : Effect.logWarning("the image conversion temporary directory could not be removed", error))));
29
+ const created = yield* bash.exec(privateDirectoryCommand(directory), { cwd: temporary, timeoutMs: Math.min(config.timeoutMs, 5000), ...environment }).pipe(Effect.mapError((cause) => resizeError("Failed", "the image conversion directory could not be created", cause)));
30
+ if (!succeeded(created))
31
+ return yield* Effect.fail(resizeError("Failed", "the image conversion directory could not be created", created));
32
+ const written = yield* bash.exec(privateInputCommand("input.img"), { cwd: directory, stdin: input.bytes, timeoutMs: config.timeoutMs, ...environment }).pipe(Effect.mapError((cause) => resizeError("Failed", "the image conversion input could not be written", cause)));
33
+ if (!succeeded(written))
34
+ return yield* Effect.fail(resizeError("Failed", "the image conversion input could not be written", written));
35
+ const convert = (side, jpeg) => Effect.gen(function* () {
36
+ const output = path.join(directory, jpeg ? "output.jpg" : "output.png"), result = yield* bash.exec(privately(imageResizeCommand(tool, "input.img", jpeg ? "output.jpg" : "output.png", side, jpeg)), { cwd: directory, timeoutMs: config.timeoutMs, ...environment }).pipe(Effect.mapError((cause) => resizeError("Failed", "the image conversion command could not run", cause)));
37
+ if (!succeeded(result))
38
+ return yield* Effect.fail(resizeError("Failed", result.termination === "timeout" ? "the image conversion command timed out" : "the image conversion command failed", result));
39
+ const bytes = yield* fs.readBytes(output).pipe(Effect.mapError((cause) => resizeError("Failed", "the converted image could not be read", cause))), outputType = sniffImageType(bytes), outputSize = Option.flatMap(outputType, (mediaType) => imageSizeOf(bytes, mediaType));
40
+ if (Option.isNone(outputSize))
41
+ return yield* Effect.fail(resizeError("Failed", "the converted image header does not give its dimensions"));
42
+ const actual = outputSize.value;
43
+ if (!(actual.width <= original.width && actual.height <= original.height || actual.width <= original.height && actual.height <= original.width))
44
+ return yield* Effect.fail(resizeError("Failed", "the image converter enlarged the source"));
45
+ if (bytes.length > bounds.maxBytes || Math.max(actual.width, actual.height) > bounds.maxSide)
46
+ return Option.none();
47
+ return yield* mediaFromBytes(bytes).pipe(Effect.as(Option.some(bytes)), Effect.mapError((cause) => resizeError("Failed", "the converted image is not valid media", cause)));
48
+ }), fallback = (side) => Effect.gen(function* () {
49
+ const command = imageOpacityCommand(tool, "input.img");
50
+ if (input.mediaType === "image/png" && command.length > 0 ? yield* bash.exec(command, { cwd: directory, timeoutMs: config.timeoutMs, ...environment }).pipe(Effect.map((result) => succeeded(result) && (result.stdout.trim().toLowerCase() === "true" || /hasAlpha:\s*no\b/i.test(result.stdout))), Effect.catch((cause) => Effect.logDebug("the source opacity could not be checked", cause).pipe(Effect.as(!1)))) : !1) {
51
+ const fitted = yield* convert(side, !0);
52
+ if (Option.isSome(fitted))
53
+ return fitted.value;
54
+ }
55
+ return yield* Effect.fail(resizeError("CannotFit", "the image could not fit the requested byte and pixel limits"));
56
+ }), attempt = (side, remaining) => convert(side, input.mediaType === "image/jpeg").pipe(Effect.flatMap(Option.match({
57
+ onSome: Effect.succeed,
58
+ onNone: () => {
59
+ const smaller = Math.max(1, Math.floor(side * 0.75));
60
+ return remaining > 1 && smaller < side ? attempt(smaller, remaining - 1) : fallback(side);
61
+ }
62
+ })));
63
+ return yield* attempt(Math.min(bounds.maxSide, Math.max(original.width, original.height)), 4);
64
+ })).pipe(Effect.timeoutOrElse({ duration: config.timeoutMs, orElse: () => Effect.fail(resizeError("Failed", "the image conversion exceeded its time limit")) }));
65
+ });
66
+ return ImageResizer.of({ fit });
67
+ });
68
+ export const ImageResizerCliLive = (options = {}) => Layer.effect(ImageResizer, makeResizer(options));
@@ -0,0 +1,23 @@
1
+ import { Schema } from "effect";
2
+ /** The optional host CLI used to fit images. @public */
3
+ export declare const ImageResizeTool: Schema.Literals<readonly ["magick", "convert", "vips", "sips"]>;
4
+ export type ImageResizeTool = typeof ImageResizeTool.Type;
5
+ /**
6
+ * Opt-in image conversion through the host's matching FileSystem and Bash.
7
+ * auto probes once in tool order. tempDir is an existing absolute directory;
8
+ * null reads the Bash environment's TMPDIR, falling back to /tmp. timeoutMs
9
+ * bounds one fit, including its conversion attempts (cleanup follows).
10
+ * maxSourcePixels refuses a source whose header declares more pixels
11
+ * (width times height) before any converter decodes it, so a small file
12
+ * that declares a huge canvas cannot hold the host's memory or disk.
13
+ * @public
14
+ */
15
+ export declare const ImageResizerConfig: Schema.Struct<{
16
+ readonly tool: Schema.Literals<readonly ["auto", "magick", "convert", "vips", "sips"]>;
17
+ readonly tempDir: Schema.NullOr<Schema.String>;
18
+ readonly timeoutMs: Schema.Int;
19
+ readonly maxSourcePixels: Schema.Int;
20
+ }>;
21
+ export type ImageResizerConfig = typeof ImageResizerConfig.Type;
22
+ /** @public */
23
+ export declare const imageResizerDefaults: ImageResizerConfig;
@@ -0,0 +1,15 @@
1
+ import type { ImageResizeTool } from "./image-resizer.entity.js";
2
+ export declare const imageToolProbe: (tool: ImageResizeTool) => string;
3
+ /** A first-frame conversion; every shell word is literal and the box never upscales. */
4
+ export declare const imageResizeCommand: (tool: ImageResizeTool, input: string, output: string, side: number, jpeg?: boolean) => string;
5
+ /** The converter's own opacity check; a backend without one keeps transparency conservatively. */
6
+ export declare const imageOpacityCommand: (tool: ImageResizeTool, input: string) => string;
7
+ /**
8
+ * Run a command so that what it creates is its owner's alone: directories
9
+ * 0700, files 0600, whatever the host's umask.
10
+ */
11
+ export declare const privately: (command: string) => string;
12
+ /** Create the conversion directory, private and new (it fails when the path exists). */
13
+ export declare const privateDirectoryCommand: (directory: string) => string;
14
+ /** Write standard input to a private file in the command's directory. */
15
+ export declare const privateInputCommand: (name: string) => string;
@@ -0,0 +1,4 @@
1
+ import { Match } from "effect";
2
+ import { shellQuote } from "./shell.entity.functions.js";
3
+ const executable = { magick: "magick", convert: "convert", vips: "vipsthumbnail", sips: "sips" };
4
+ export const imageToolProbe = (tool) => [executable[tool], tool === "magick" || tool === "convert" ? "-version" : "--version"].map(shellQuote).join(" "), imageResizeCommand = (tool, input, output, side, jpeg = !1) => Match.value(tool).pipe(Match.when("magick", () => ["magick", `${input}[0]`, "-coalesce", "-auto-orient", "-strip", "-resize", `${side}x${side}>`, "-quality", "85", output]), Match.when("convert", () => ["convert", `${input}[0]`, "-coalesce", "-auto-orient", "-strip", "-resize", `${side}x${side}>`, "-quality", "85", output]), Match.when("vips", () => ["vipsthumbnail", input, "--size", `${side}x${side}>`, "--path", `${output}[strip${jpeg ? ",Q=85" : ""}]`]), Match.when("sips", () => ["sips", "-s", "format", jpeg ? "jpeg" : "png", ...jpeg ? ["-s", "formatOptions", "85"] : [], "-Z", String(side), input, "--out", output]), Match.exhaustive, (args) => args.map(shellQuote).join(" ")), imageOpacityCommand = (tool, input) => Match.value(tool).pipe(Match.when("magick", () => ["magick", `${input}[0]`, "-format", "%[opaque]", "info:"]), Match.when("convert", () => ["convert", `${input}[0]`, "-format", "%[opaque]", "info:"]), Match.when("vips", () => []), Match.when("sips", () => ["sips", "-g", "hasAlpha", input]), Match.exhaustive, (args) => args.map(shellQuote).join(" ")), privately = (command) => `umask 077 && ${command}`, privateDirectoryCommand = (directory) => privately(["mkdir", "--", directory].map(shellQuote).join(" ")), privateInputCommand = (name) => privately(`cat > ${shellQuote(name)}`);
@@ -0,0 +1,7 @@
1
+ import { Schema } from "effect";
2
+ export const ImageResizeTool = Schema.Literals(["magick", "convert", "vips", "sips"]), ImageResizerConfig = Schema.Struct({
3
+ tool: Schema.Literals(["auto", ...ImageResizeTool.literals]),
4
+ tempDir: Schema.NullOr(Schema.String.check(Schema.makeFilter((path) => path.startsWith("/") && !path.includes("\x00") ? !0 : "an absolute POSIX directory"))),
5
+ timeoutMs: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 600000 })),
6
+ maxSourcePixels: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 1e9 }))
7
+ }), imageResizerDefaults = { tool: "auto", tempDir: null, timeoutMs: 30000, maxSourcePixels: 1e8 };
@@ -0,0 +1,14 @@
1
+ export { BashLive, makeBash } from "./bash.adapter.js";
2
+ export { FileSystemLive, localFileSystem } from "./file-system.adapter.js";
3
+ export { LocalSystemConfig, localSystemDefaults } from "./local-system.entity.js";
4
+ export type { BashLiveOptions } from "./local-system.entity.js";
5
+ export { fsReasonOf } from "./local-system.entity.functions.js";
6
+ export { localSystemPlugin, LocalSystemPluginLive, workspaceToolsPlugin, WorkspaceToolsPluginLive } from "./plugin.adapter.js";
7
+ export { SkillsDirectoryConfig, skillsDirectoryDefaults } from "./skills/skills-directory.entity.js";
8
+ export { ALWAYS_SECTION_ID, SKILLS_CAPABILITY_ID, skillsCapability } from "./skills/skills-directory.entity.functions.js";
9
+ export { loadSkillsDirectory, skillsDirectoryPlugin, SkillsDirectoryPluginLive } from "./skills/skills-directory.adapter.js";
10
+ export { workspaceTools } from "./workspace-tools.adapter.js";
11
+ export { BashParams, BashToolResult, EditParams, EditResult, FindParams, FindResult, GrepLine, GrepParams, GrepResult, GrepUnreadable, LsEntry, LsParams, LsResult, ReadParams, ReadResult, TextEdit, WORKSPACE_PERMISSIONS, WorkspaceSkillName, WorkspaceToolName, workspaceToolSchemas, WorkspaceToolsConfig, workspaceToolsDefaults, WriteParams, WriteResult, } from "./workspace-tools.entity.js";
12
+ export { ImageResizerCliLive } from "./image-resizer.adapter.js";
13
+ export { ImageResizeTool, ImageResizerConfig, imageResizerDefaults } from "./image-resizer.entity.js";
14
+ export { imageResizerPlugin, ImageResizerPluginLive } from "./plugin.adapter.js";
package/dist/index.js ADDED
@@ -0,0 +1,38 @@
1
+ export { BashLive, makeBash } from "./bash.adapter.js";
2
+ export { FileSystemLive, localFileSystem } from "./file-system.adapter.js";
3
+ export { LocalSystemConfig, localSystemDefaults } from "./local-system.entity.js";
4
+ export { fsReasonOf } from "./local-system.entity.functions.js";
5
+ export { localSystemPlugin, LocalSystemPluginLive, workspaceToolsPlugin, WorkspaceToolsPluginLive } from "./plugin.adapter.js";
6
+ export { SkillsDirectoryConfig, skillsDirectoryDefaults } from "./skills/skills-directory.entity.js";
7
+ export { ALWAYS_SECTION_ID, SKILLS_CAPABILITY_ID, skillsCapability } from "./skills/skills-directory.entity.functions.js";
8
+ export { loadSkillsDirectory, skillsDirectoryPlugin, SkillsDirectoryPluginLive } from "./skills/skills-directory.adapter.js";
9
+ export { workspaceTools } from "./workspace-tools.adapter.js";
10
+ export {
11
+ BashParams,
12
+ BashToolResult,
13
+ EditParams,
14
+ EditResult,
15
+ FindParams,
16
+ FindResult,
17
+ GrepLine,
18
+ GrepParams,
19
+ GrepResult,
20
+ GrepUnreadable,
21
+ LsEntry,
22
+ LsParams,
23
+ LsResult,
24
+ ReadParams,
25
+ ReadResult,
26
+ TextEdit,
27
+ WORKSPACE_PERMISSIONS,
28
+ WorkspaceSkillName,
29
+ WorkspaceToolName,
30
+ workspaceToolSchemas,
31
+ WorkspaceToolsConfig,
32
+ workspaceToolsDefaults,
33
+ WriteParams,
34
+ WriteResult
35
+ } from "./workspace-tools.entity.js";
36
+ export { ImageResizerCliLive } from "./image-resizer.adapter.js";
37
+ export { ImageResizeTool, ImageResizerConfig, imageResizerDefaults } from "./image-resizer.entity.js";
38
+ export { imageResizerPlugin, ImageResizerPluginLive } from "./plugin.adapter.js";