Summary
Rebuild the llm CLI as a safe-bash command pack. safe-bash ships only the primitives: argument parsing, stdin and attachment handling, text or binary output, the provider contract and model lookup. Providers and the actual querying are injected by the consumer, following the networkCommands({ transport }) pattern in packages/safe-bash/src/commands/network. The pack accepts many providers at once and routes each request by model id. Ship OpenAI and ElevenLabs providers as reference plugins.
Scope
Command pack packages/safe-bash/src/commands/llm
createLlmCommands(options) and llmCommands(options) plugin, same shape as createNetworkCommands / networkCommands.
LlmCommandsOptions: providers: readonly LlmProvider[], defaultModel?: string, replace?: boolean.
- Registers one command:
llm.
- Model lookup: every provider contributes its models.
-m resolves an id or alias across all providers; duplicate ids across providers are a registration error.
CLI surface (mirrors llm, cli only)
llm [prompt]. Prompt from args, stdin, or both. When both are present stdin is the content and the arg is the instruction, as in cat file | llm 'summarize'.
-m, --model <id>: pick a model by id or alias. Falls back to defaultModel. Unknown model: exit 1 with Unknown model: <id> on stderr.
-s, --system <text>: system prompt.
-o, --option <key> <value>: repeatable, passed through to the provider untouched.
-a, --attachment <path>: repeatable. Path is read from the sandbox filesystem, mime type sniffed from magic bytes with extension fallback. Images, audio and videos are the primary use case; any type the model declares is accepted.
--at <path> <mimetype>: repeatable, same as -a with an explicit mime type.
- Attachments the selected model does not accept fail before the request with exit 1 and
Model <id> does not accept <mimetype> on stderr. Attachment bytes count against existing input budgets.
llm models: list provider/model ids, aliases, accepted attachment types and output type from the injected providers.
- Output: text models write UTF-8 chunks as they arrive followed by a newline. Binary models (
image/png, video/mp4, audio/mpeg, ...) write raw bytes with no trailing newline, so > file.png and | base64 work like any other command. Mixing text and binary in one response is a provider error.
- Honors the shell abort signal and existing output budgets.
Provider contract exported from the pack
interface LlmProvider {
readonly name: string;
readonly models: readonly LlmModel[];
complete(request: LlmRequest): AsyncIterable<string | Uint8Array>;
}
interface LlmModel {
readonly id: string;
readonly aliases?: readonly string[];
readonly attachmentTypes?: readonly string[]; // mime types or "image/*", "video/*", "audio/*"
readonly outputType?: string; // default "text/plain"; e.g. "image/png", "audio/mpeg"
}
interface LlmRequest {
model: string;
prompt: string;
system?: string;
attachments: readonly { mimeType: string; bytes: Uint8Array }[];
options: Readonly<Record<string, string>>;
signal: AbortSignal;
}
Examples
Consumer wiring, one shell with two providers:
const transport = createFetchTransport({ authorize: createOriginAuthorizer({ allow: ["api.openai.com", "api.elevenlabs.io"] }) });
const openai = createOpenAiProvider({
transport,
apiKey: env.OPENAI_API_KEY,
models: [
{ id: "gpt-4.1", aliases: ["4.1"], endpoint: "chat", attachmentTypes: ["image/*"] },
{ id: "gpt-image-1", endpoint: "images", attachmentTypes: ["image/*"], outputType: "image/png" },
{ id: "sora-2", endpoint: "videos", attachmentTypes: ["image/*"], outputType: "video/mp4" },
],
});
const elevenlabs = createElevenLabsProvider({
transport,
apiKey: env.ELEVENLABS_API_KEY,
models: [
{ id: "eleven_multilingual_v2", aliases: ["tts"], endpoint: "tts", outputType: "audio/mpeg" },
{ id: "music_v1", aliases: ["music"], endpoint: "music", outputType: "audio/mpeg" },
],
});
const shell = new Shell({ fs, cwd: "/work" })
.use(agentCommands())
.use(llmCommands({ providers: [openai, elevenlabs], defaultModel: "gpt-4.1" }));
Scripts running inside that shell:
# text
cat notes.md | llm 'turn these into a changelog'
# describe an image
llm -a photo.jpg 'what is in this picture'
# create an image
llm -m gpt-image-1 'a watercolor fox reading a newspaper' -o size 1024x1024 > fox.png
# edit an image (attachments present -> images/edits endpoint)
llm -m gpt-image-1 -a fox.png 'give the fox a red scarf' > fox-scarf.png
# create a video
llm -m sora-2 'the fox folds the newspaper and walks away' -o seconds 8 > fox.mp4
# animate a still image into a video
llm -m sora-2 -a fox-scarf.png 'slow pan, gentle wind' > fox-pan.mp4
# text to speech via elevenlabs
llm -m tts -o voice_id JBFqnCBsd6RMkjVDRZzb 'The fox has left the building.' > fox.mp3
cat script.txt | llm -m tts > narration.mp3
# create music via elevenlabs
llm -m music 'lo-fi jazz loop, brushed drums, warm upright bass' -o music_length_ms 30000 > loop.mp3
# instrumental only, longer track
llm -m music 'cinematic strings building to a brass climax' -o music_length_ms 120000 -o force_instrumental true > score.mp3
# chain: describe -> narrate
llm -a fox.png 'one sentence caption' | llm -m tts > caption.mp3
# chain: describe the mood of a video -> soundtrack for it
llm -a fox.mp4 'describe the mood in one line as a music prompt' | llm -m music -o music_length_ms 8000 > fox-soundtrack.mp3
Reference plugins
createOpenAiProvider({ transport, apiKey, baseUrl?, models })
- Uses the same injected
NetworkTransport as curl, so no direct network access from the pack.
- Each configured model carries
endpoint: "chat" | "images" | "videos". The provider has no hardcoded model knowledge.
chat: streams POST /chat/completions, yields delta.content. Image attachments become image_url parts with base64 data URIs.
images: POST /images/generations, or POST /images/edits when attachments are present. Yields the decoded b64_json bytes. -o keys map straight to request fields (size, quality, background).
videos: POST /videos, polls the job until completed honoring the abort signal, then downloads /videos/{id}/content and yields the bytes. An image attachment is sent as input_reference. -o seconds, -o size pass through.
- Works against any OpenAI-compatible endpoint, including Poe, by setting
baseUrl.
createElevenLabsProvider({ transport, apiKey, baseUrl?, models })
- Each configured model carries
endpoint: "tts" | "music". The provider has no hardcoded model knowledge.
tts: POST /v1/text-to-speech/{voice_id} with model_id set to the selected model, output_format derived from the model's outputType. Yields the streamed audio bytes. voice_id comes from -o voice_id <id> or a per-model defaultVoiceId in the consumer config; missing voice exits 1 with a clear message. Remaining -o keys pass through as voice_settings fields (stability, similarity_boost, speed).
music: POST /v1/music with the prompt as prompt, model_id set to the selected model, output_format derived from outputType. -o music_length_ms and -o force_instrumental pass through as request fields. Yields the audio bytes. No attachments accepted.
- Proves the multi-provider and binary-output paths with a provider that is not OpenAI-compatible.
Out of scope
- No key storage or
llm keys. Auth belongs to the injected provider.
- No
llm chat or any interactive session. Single request, single response.
- No SQLite logging,
llm logs, templates, embeddings, llm install, or conversation continuation (-c).
- No URL attachments in v1. Files come from the sandbox filesystem only.
- Not a poe-code CLI command. This is a safe-bash tool only.
Requirements
- TDD. Tests use fake in-memory providers, never the network. OpenAI and ElevenLabs provider tests use a fake transport and fixed fixture bytes.
- README for the pack listing every option, the provider contract, attachment and binary-output behavior, linked from the safe-bash README command list.
- No provider-specific branching in the command. Everything is derived from the injected
LlmProvider list and each model's declared attachmentTypes and outputType.
Summary
Rebuild the
llmCLI as a safe-bash command pack. safe-bash ships only the primitives: argument parsing, stdin and attachment handling, text or binary output, the provider contract and model lookup. Providers and the actual querying are injected by the consumer, following thenetworkCommands({ transport })pattern inpackages/safe-bash/src/commands/network. The pack accepts many providers at once and routes each request by model id. Ship OpenAI and ElevenLabs providers as reference plugins.Scope
Command pack
packages/safe-bash/src/commands/llmcreateLlmCommands(options)andllmCommands(options)plugin, same shape ascreateNetworkCommands/networkCommands.LlmCommandsOptions:providers: readonly LlmProvider[],defaultModel?: string,replace?: boolean.llm.-mresolves an id or alias across all providers; duplicate ids across providers are a registration error.CLI surface (mirrors
llm, cli only)llm [prompt]. Prompt from args, stdin, or both. When both are present stdin is the content and the arg is the instruction, as incat file | llm 'summarize'.-m, --model <id>: pick a model by id or alias. Falls back todefaultModel. Unknown model: exit 1 withUnknown model: <id>on stderr.-s, --system <text>: system prompt.-o, --option <key> <value>: repeatable, passed through to the provider untouched.-a, --attachment <path>: repeatable. Path is read from the sandbox filesystem, mime type sniffed from magic bytes with extension fallback. Images, audio and videos are the primary use case; any type the model declares is accepted.--at <path> <mimetype>: repeatable, same as-awith an explicit mime type.Model <id> does not accept <mimetype>on stderr. Attachment bytes count against existing input budgets.llm models: listprovider/modelids, aliases, accepted attachment types and output type from the injected providers.image/png,video/mp4,audio/mpeg, ...) write raw bytes with no trailing newline, so> file.pngand| base64work like any other command. Mixing text and binary in one response is a provider error.Provider contract exported from the pack
Examples
Consumer wiring, one shell with two providers:
Scripts running inside that shell:
Reference plugins
createOpenAiProvider({ transport, apiKey, baseUrl?, models })NetworkTransportascurl, so no direct network access from the pack.endpoint: "chat" | "images" | "videos". The provider has no hardcoded model knowledge.chat: streamsPOST /chat/completions, yieldsdelta.content. Image attachments becomeimage_urlparts with base64 data URIs.images:POST /images/generations, orPOST /images/editswhen attachments are present. Yields the decodedb64_jsonbytes.-okeys map straight to request fields (size,quality,background).videos:POST /videos, polls the job untilcompletedhonoring the abort signal, then downloads/videos/{id}/contentand yields the bytes. An image attachment is sent asinput_reference.-o seconds,-o sizepass through.baseUrl.createElevenLabsProvider({ transport, apiKey, baseUrl?, models })endpoint: "tts" | "music". The provider has no hardcoded model knowledge.tts:POST /v1/text-to-speech/{voice_id}withmodel_idset to the selected model,output_formatderived from the model'soutputType. Yields the streamed audio bytes.voice_idcomes from-o voice_id <id>or a per-modeldefaultVoiceIdin the consumer config; missing voice exits 1 with a clear message. Remaining-okeys pass through asvoice_settingsfields (stability,similarity_boost,speed).music:POST /v1/musicwith the prompt asprompt,model_idset to the selected model,output_formatderived fromoutputType.-o music_length_msand-o force_instrumentalpass through as request fields. Yields the audio bytes. No attachments accepted.Out of scope
llm keys. Auth belongs to the injected provider.llm chator any interactive session. Single request, single response.llm logs, templates, embeddings,llm install, or conversation continuation (-c).Requirements
LlmProviderlist and each model's declaredattachmentTypesandoutputType.