Wrapping existing CLI operations as typed, composable tasks
State: open/active · Genre: analysis
Use case: cli-ops-as-typed-tasks RFC mapping: UC10 Source: cross-repo survey of
sksizer/* (2026-07-05), companion to the workflow-constellation RFC (PR #682).
The need
Section titled “The need”Deterministic git/gh/cargo/limactl/domain-CLI operations get wrapped by hand into typed operations over and over — entire crates exist solely for this — and where no wrapper exists, the contract degrades into exit-code dispatch tables and stdout micro-grammars restated in prose at every call site. The spawn/capture/status-check/typed-error pattern is the same everywhere.
Evidence
Section titled “Evidence”rust-worker-prototype
Section titled “rust-worker-prototype”fluent-git: a whole workspace crate built to turn git subprocess calls into typed, error-mapped fluent operations before any workflow could use them; BashStep/BashCommand do the same for arbitrary commands.
Where: crates/fluent-git/src/cmd.rs · crates/fluent-git/src/error.rs ·
src/steps/bash/bash_step.rs
Pain: UC10 cost a sibling crate of boilerplate before the workflow itself was even started.
rust-fluent-git
Section titled “rust-fluent-git”Five sibling crates (fluent-git, fluent-gh, fluent-claude, fluent-brew, fluent-lima) — the repo’s entire thesis is typed CLI wrapping atop shared cmd-spec/fluent-core execution infrastructure.
Where: crates/fluent-gh/src/lib.rs · crates/fluent-core/src/run.rs
Pain: The typed ops exist but have no composition layer to be consumed by.
harness
Section titled “harness”487-line LimaManager wrapping ~12 limactl/brew invocations as typed async fns, each repeating spawn/collect/status-check/stderr-context boilerplate, plus temp-file script upload to dodge shell escaping.
Where: prototypes/zoo/code2/src-tauri/src/vm/lima.rs
Pain: The repeated wrapper boilerplate dwarfs the domain logic.
Every gated CLI op’s contract is restated in consuming SKILL.md files as exit-code → action tables plus stdout line grammars (ACQUIRED task=…, PENDING reasons=…, clean/contaminated/stale-base), with house rules against piping because pipes mask exit codes.
Where: plugin/skills/task-work/SKILL.md · plugin/skills/orchestrate/SKILL.md ·
plugin/lib/services/quality/run-checks.ts
Pain: Each op’s contract is duplicated in prose per consumer — a whole class of prose-drift bugs typed result unions would eliminate.
rust-cmd-spec
Section titled “rust-cmd-spec”ShellCommand as serde-serializable data, converted to a process Command only at execution time — the exact representation a durable journal of CLI steps needs, built with no orchestration consumer.
Where: src/lib.rs · README.md
Pain: A hand-built building block waiting for the library that should own it.
DarkFactory
Section titled “DarkFactory”~25 builtin operation modules wrapping git/gh/shell verbs (commit, push_branch, create_pr, set_status, …) as the BuiltIn task registry of the bespoke Python engine.
Where: python/darkfactory/operations/
Pain: A full registry pattern maintained by hand inside a bespoke engine.
Flowline fit
Section titled “Flowline fit”UC10 in @flow/workflow. Design note from the evidence: task definitions should be serializable (rust-cmd-spec’s commands-as-data) so @flow/durable can journal and replay them, and typed result unions should replace exit-code contracts at the API boundary.
Proposed API
Section titled “Proposed API”Builds on: 01 Introduces: task(), payload(), taskFromOp()
Wrap a CLI verb once; every consumer gets a typed result instead of an exit-code table restated in prose at each call site:
import { task, payload, taskFromOp } from "flowline/workflow";import { z } from "zod";
const Branch = payload("Branch", z.object({ name: z.string() }));const Pr = payload("Pr", z.object({ number: z.number(), url: z.string() }));
const createPr = task("create-pr") .reads(Branch) .writes(Pr) .run(async ({ read, exec }) => { const out = await exec("gh", ["pr", "create", "--head", read(Branch).name]); return Pr.of(parsePr(out.stdout)); // parsing lives in exactly one place });
// SDLC bridge (UC10 proper): an existing defineOp op becomes a task unchanged,// OpError -> typed failure at the boundary.const acquire = taskFromOp(leaseAcquireOp, (ctx) => ({ task: ctx.read(TaskId).id }));exec on the task context is proposed sugar: commands as serializable data
(rust-cmd-spec’s thesis), so a durable journal can record the command itself, not only its
result.