Skip to content

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).

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.

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.

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.

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.

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.

~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.

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.

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.


← Back to Notes