diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index 03a8d14d51..29e43ef457 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -4,7 +4,7 @@ import { Command } from "@cliffy/command"; import { Confirm } from "@cliffy/prompt/confirm"; import { Table } from "@cliffy/table"; import * as log from "../../core/log.ts"; -import { sep as SEP } from "node:path"; +import { dirname, sep as SEP } from "node:path"; import { stringify as yamlStringify } from "yaml"; import { yamlParseFile } from "../../utils/yaml.ts"; import { readTextFile, validateRequiredArgs } from "../../utils/utils.ts"; @@ -562,7 +562,10 @@ async function preview( if (!isFlowDir) { // Check if it's a flow.yaml file if (flowPath.endsWith("flow.yaml") || flowPath.endsWith("flow.json")) { - flowPath = flowPath.substring(0, flowPath.lastIndexOf(SEP)); + // Use dirname so a bare "flow.yaml" (no parent dir) becomes "." + // instead of "" — the latter, after appending SEP below, becomes "/" + // and silently reads from filesystem root. + flowPath = dirname(flowPath); } else { throw new Error( "Flow path must be a .flow/__flow directory or a flow.yaml file" @@ -674,6 +677,185 @@ async function preview( } } +function findStepInFlowValue(flowValue: any, stepId: string): any | undefined { + if (!flowValue) return undefined; + if (flowValue.failure_module?.id === stepId) return flowValue.failure_module; + if (flowValue.preprocessor_module?.id === stepId) return flowValue.preprocessor_module; + return findStepInModules(flowValue.modules ?? [], stepId); +} + +function findStepInModules(modules: any[], stepId: string): any | undefined { + for (const m of modules) { + if (m?.id === stepId) return m; + const v = m?.value; + if (!v) continue; + if (v.type === "forloopflow" || v.type === "whileloopflow") { + const found = findStepInModules(v.modules ?? [], stepId); + if (found) return found; + } else if (v.type === "branchone") { + for (const b of v.branches ?? []) { + const found = findStepInModules(b.modules ?? [], stepId); + if (found) return found; + } + const found = findStepInModules(v.default ?? [], stepId); + if (found) return found; + } else if (v.type === "branchall") { + for (const b of v.branches ?? []) { + const found = findStepInModules(b.modules ?? [], stepId); + if (found) return found; + } + } + } + return undefined; +} + +function collectStepIds(flowValue: any): string[] { + const ids: string[] = []; + const walkModules = (modules: any[]) => { + for (const m of modules) { + if (m?.id) ids.push(m.id); + const v = m?.value; + if (!v) continue; + if (v.type === "forloopflow" || v.type === "whileloopflow") { + walkModules(v.modules ?? []); + } else if (v.type === "branchone") { + for (const b of v.branches ?? []) walkModules(b.modules ?? []); + walkModules(v.default ?? []); + } else if (v.type === "branchall") { + for (const b of v.branches ?? []) walkModules(b.modules ?? []); + } + } + }; + if (flowValue?.preprocessor_module?.id) ids.push(flowValue.preprocessor_module.id); + if (flowValue?.failure_module?.id) ids.push(flowValue.failure_module.id); + walkModules(flowValue?.modules ?? []); + return ids; +} + +async function testStep( + opts: GlobalOptions & { + data?: string; + silent: boolean; + json?: boolean; + } & SyncOptions, + flowPath: string, + stepId: string +) { + if (opts.silent || opts.json) log.setSilent(true); + opts = await mergeConfigWithConfigFile(opts); + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + // Normalize flow path (same logic as `preview`). + const isFlowDir = flowPath.endsWith(".flow") || flowPath.endsWith(".flow" + SEP) + || flowPath.endsWith("__flow") || flowPath.endsWith("__flow" + SEP); + if (!isFlowDir) { + if (flowPath.endsWith("flow.yaml") || flowPath.endsWith("flow.json")) { + // Use dirname so a bare "flow.yaml" (no parent dir) becomes "." + // instead of "" — the latter, after appending SEP below, becomes "/" + // and silently reads from filesystem root. + flowPath = dirname(flowPath); + } else { + throw new Error( + "Flow path must be a .flow/__flow directory or a flow.yaml file" + ); + } + } + if (!flowPath.endsWith(SEP)) flowPath += SEP; + + const localFlow = (await yamlParseFile(flowPath + "flow.yaml")) as FlowFile; + const fileReader = async (path: string) => await readTextFile(flowPath + path); + await replaceInlineScripts(localFlow.value.modules, fileReader, log, flowPath, SEP); + if (localFlow.value.failure_module) { + await replaceInlineScripts([localFlow.value.failure_module], fileReader, log, flowPath, SEP); + } + if (localFlow.value.preprocessor_module) { + await replaceInlineScripts([localFlow.value.preprocessor_module], fileReader, log, flowPath, SEP); + } + + const module = findStepInFlowValue(localFlow.value, stepId); + if (!module) { + const available = collectStepIds(localFlow.value).join(", ") || "(none)"; + throw new Error(`Step '${stepId}' not found in flow. Available steps: ${available}`); + } + + const baseArgs = opts.data ? await resolve(opts.data) : {}; + const args = + stepId === "preprocessor" + ? { _ENTRYPOINT_OVERRIDE: "preprocessor", ...baseArgs } + : baseArgs; + + const moduleValue = module.value; + let jobId: string; + if (moduleValue?.type === "rawscript") { + if (!opts.silent && !opts.json) { + log.info(colors.yellow(`Testing rawscript step '${stepId}' (${moduleValue.language})...`)); + } + jobId = await wmill.runScriptPreview({ + workspace: workspace.workspaceId, + requestBody: { + content: moduleValue.content ?? "", + language: moduleValue.language, + args, + }, + }); + } else if (moduleValue?.type === "script") { + if (!opts.silent && !opts.json) { + log.info(colors.yellow(`Testing script step '${stepId}' (${moduleValue.path})...`)); + } + const script = moduleValue.hash + ? await wmill.getScriptByHash({ + workspace: workspace.workspaceId, + hash: moduleValue.hash, + }) + : await wmill.getScriptByPath({ + workspace: workspace.workspaceId, + path: moduleValue.path, + }); + jobId = await wmill.runScriptPreview({ + workspace: workspace.workspaceId, + requestBody: { + content: script.content, + language: script.language as any, + args, + }, + }); + } else if (moduleValue?.type === "flow") { + if (!opts.silent && !opts.json) { + log.info(colors.yellow(`Testing flow step '${stepId}' (${moduleValue.path})...`)); + } + jobId = await wmill.runFlowByPath({ + workspace: workspace.workspaceId, + path: moduleValue.path, + requestBody: args, + }); + } else { + throw new Error( + `Cannot test step of type '${moduleValue?.type ?? "unknown"}'. Supported types: rawscript, script, flow.` + ); + } + + const { result, success } = await pollForJobResult(workspace.workspaceId, jobId); + + if (!success) { + if (opts.silent || opts.json) { + console.log(JSON.stringify(result)); + } else { + log.info(colors.yellow.bold(`Step '${stepId}' failed:`)); + log.info(JSON.stringify(result, null, 2)); + } + process.exitCode = 1; + return; + } + + if (opts.silent || opts.json) { + console.log(JSON.stringify(result)); + } else { + log.info(colors.bold.underline.green(`Step '${stepId}' completed`)); + log.info(JSON.stringify(result, null, 2)); + } +} + export async function generateLocks( opts: GlobalOptions & { yes?: boolean; @@ -906,6 +1088,21 @@ const command = new Command() "Use deployed workspace scripts for PathScript steps instead of local files." ) .action(preview as any) + .command( + "test-step", + "Test a single step of a local flow in isolation. Resolves the step by id (including nested branchone/branchall/forloopflow/whileloopflow and the failure/preprocessor modules), runs only that step's runnable. Supported step types: rawscript, script (PathScript), flow (PathFlow)." + ) + .arguments(" ") + .option( + "-d --data ", + "Step inputs as a JSON string or a file using @ or stdin using @-." + ) + .option( + "-s --silent", + "Do not output anything other then the final output. Useful for scripting." + ) + .option("--json", "Output the result as JSON (same as --silent)") + .action(testStep as any) .command( "generate-locks", 'DEPRECATED: re-generate flow lock files. Use "wmill generate-metadata" instead.' diff --git a/cli/src/commands/object-storage/object-storage.ts b/cli/src/commands/object-storage/object-storage.ts new file mode 100644 index 0000000000..2ce404e292 --- /dev/null +++ b/cli/src/commands/object-storage/object-storage.ts @@ -0,0 +1,344 @@ +import { Buffer } from "node:buffer"; +import { readFile, writeFile } from "node:fs/promises"; +import { basename } from "node:path"; + +import { GlobalOptions } from "../../types.ts"; +import { requireLogin } from "../../core/auth.ts"; +import { resolveWorkspace } from "../../core/context.ts"; +import { Command } from "@cliffy/command"; +import { Confirm } from "@cliffy/prompt/confirm"; +import { Table } from "@cliffy/table"; +import { colors } from "@cliffy/ansi/colors"; +import * as log from "../../core/log.ts"; +import * as wmill from "../../../gen/services.gen.ts"; +import { formatTimestamp } from "../../utils/utils.ts"; + +function formatBytes(n: number | undefined): string { + if (n == null) return "-"; + if (n < 1024) return `${n}B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}K`; + if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)}M`; + return `${(n / (1024 * 1024 * 1024)).toFixed(2)}G`; +} + +async function listStorages( + opts: GlobalOptions & { json?: boolean } +) { + if (opts.json) log.setSilent(true); + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + const names = await wmill.getSecondaryStorageNames({ + workspace: workspace.workspaceId, + includeDefault: true, + }); + + if (opts.json) { + console.log(JSON.stringify(names)); + return; + } + if (names.length === 0) { + log.info("No object storage configured for this workspace."); + return; + } + for (const name of names) { + console.log(name === "_default_" ? `${name} ${colors.dim("(default)")}` : name); + } +} + +async function listFiles( + opts: GlobalOptions & { + json?: boolean; + maxKeys?: number; + marker?: string; + storage?: string; + }, + prefix?: string +) { + if (opts.json) log.setSilent(true); + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + const result = await wmill.listStoredFiles({ + workspace: workspace.workspaceId, + maxKeys: opts.maxKeys ?? 100, + marker: opts.marker, + prefix, + storage: opts.storage, + }); + + if (opts.json) { + console.log(JSON.stringify(result)); + return; + } + const files = result.windmill_large_files ?? []; + if (files.length === 0) { + log.info("No files found."); + return; + } + new Table() + .header(["Key"]) + .padding(2) + .border(true) + .body(files.map((f) => [f.s3])) + .render(); + if (result.next_marker) { + log.info(`\nMore results available. Use --marker '${result.next_marker}' to paginate.`); + } +} + +async function upload( + opts: GlobalOptions & { + storage?: string; + contentType?: string; + contentDisposition?: string; + }, + localPath: string, + fileKey: string +) { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + const buf = await readFile(localPath); + // Wrap Node Buffer in a Blob for the SDK request body. + const blob = new Blob([buf], { type: opts.contentType ?? "application/octet-stream" }); + + await wmill.fileUpload({ + workspace: workspace.workspaceId, + fileKey, + storage: opts.storage, + contentType: opts.contentType, + contentDisposition: opts.contentDisposition, + requestBody: blob, + }); + log.info(colors.green(`Uploaded ${localPath} -> ${fileKey}`)); +} + +async function download( + opts: GlobalOptions & { storage?: string; stdout?: boolean }, + fileKey: string, + outputPath?: string +) { + if (opts.stdout) log.setSilent(true); + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + // The generated request layer (cli/gen/core/request.ts:getResponseBody) + // routes by Content-Type: binary types → Blob, text/* → string, JSON → object. + // The generated return type is `Blob | File`, which is wrong for non-binary + // responses, so widen to unknown before normalizing. + const body: unknown = await wmill.fileDownload({ + workspace: workspace.workspaceId, + fileKey, + storage: opts.storage, + }); + let buf: Buffer; + if (typeof body === "string") { + buf = Buffer.from(body, "utf-8"); + } else if (body instanceof Blob) { + buf = Buffer.from(await body.arrayBuffer()); + } else if (body instanceof ArrayBuffer) { + buf = Buffer.from(body); + } else if (body == null) { + buf = Buffer.alloc(0); + } else { + buf = Buffer.from(JSON.stringify(body), "utf-8"); + } + + if (opts.stdout) { + process.stdout.write(buf); + return; + } + const dest = outputPath ?? basename(fileKey); + await writeFile(dest, buf); + log.info(colors.green(`Downloaded ${fileKey} -> ${dest}`)); +} + +async function del( + opts: GlobalOptions & { storage?: string; yes?: boolean }, + fileKey: string +) { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + if (!opts.yes) { + const confirmed = await Confirm.prompt({ + message: `Delete '${fileKey}' from object storage${opts.storage ? ` (storage: ${opts.storage})` : ""}?`, + default: false, + }); + if (!confirmed) { + log.info("Aborted."); + return; + } + } + + await wmill.deleteS3File({ + workspace: workspace.workspaceId, + fileKey, + storage: opts.storage, + }); + log.info(colors.green(`Deleted ${fileKey}`)); +} + +async function move( + opts: GlobalOptions & { storage?: string }, + srcFileKey: string, + destFileKey: string +) { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + await wmill.moveS3File({ + workspace: workspace.workspaceId, + srcFileKey, + destFileKey, + storage: opts.storage, + }); + log.info(colors.green(`Moved ${srcFileKey} -> ${destFileKey}`)); +} + +async function info( + opts: GlobalOptions & { json?: boolean; storage?: string }, + fileKey: string +) { + if (opts.json) log.setSilent(true); + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + const metadata = await wmill.loadFileMetadata({ + workspace: workspace.workspaceId, + fileKey, + storage: opts.storage, + }); + + if (opts.json) { + console.log(JSON.stringify(metadata)); + return; + } + console.log(colors.bold("Key:") + " " + fileKey); + console.log(colors.bold("Size:") + " " + formatBytes(metadata.size_in_bytes)); + console.log(colors.bold("Mime:") + " " + (metadata.mime_type ?? "-")); + console.log( + colors.bold("Last Modified:") + " " + + (metadata.last_modified ? formatTimestamp(metadata.last_modified) : "-") + ); + if (metadata.expires) { + console.log(colors.bold("Expires:") + " " + formatTimestamp(metadata.expires)); + } + if (metadata.version_id) { + console.log(colors.bold("Version Id:") + " " + metadata.version_id); + } +} + +async function preview( + opts: GlobalOptions & { + storage?: string; + bytesFrom?: number; + bytesLength?: number; + csvSeparator?: string; + csvHeader?: boolean; + mime?: string; + }, + fileKey: string +) { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + // Backend requires both byte fields; mirror the frontend's defaults + // (frontend/src/lib/components/S3FilePickerInner.svelte) for an interactive + // peek so the user gets useful output without passing flags. + const result = await wmill.loadFilePreview({ + workspace: workspace.workspaceId, + fileKey, + storage: opts.storage, + fileMimeType: opts.mime, + readBytesFrom: opts.bytesFrom ?? 0, + readBytesLength: opts.bytesLength ?? 128 * 1024, + csvSeparator: opts.csvSeparator, + csvHasHeader: opts.csvHeader, + }); + + if (result.msg) { + log.info(colors.yellow(result.msg)); + } + if (result.content != null) { + process.stdout.write(result.content); + if (!result.content.endsWith("\n")) process.stdout.write("\n"); + } +} + +const command = new Command() + .alias("s3") + .description("Object storage (S3) related commands. Operates on the workspace's default object storage; use --storage to target a configured secondary storage.") + .action(listStorages as any) + .command( + "list", + "List configured object storages for the workspace (default + secondary)." + ) + .option("--json", "Output as JSON (for piping to jq)") + .action(listStorages as any) + .command( + "files", + "List files in an object storage. Optionally filter by prefix." + ) + .alias("ls") + .arguments("[prefix:string]") + .option("--json", "Output as JSON (for piping to jq)") + .option("--max-keys ", "Page size (default 100)") + .option("--marker ", "Pagination marker from a previous response") + .option("--storage ", "Secondary storage name (omit for the workspace default)") + .action(listFiles as any) + .command( + "upload", + "Upload a local file to object storage at the given file key." + ) + .arguments(" ") + .option("--storage ", "Secondary storage name") + .option("--content-type ", "Content-Type header to set on the object") + .option("--content-disposition ", "Content-Disposition header to set on the object") + .action(upload as any) + .command( + "download", + "Download an object to a local file (or stdout). Default output path is the basename of the file key in the current directory." + ) + .arguments(" [output_path:string]") + .option("--storage ", "Secondary storage name") + .option("--stdout", "Write file contents to stdout instead of a file") + .action(download as any) + .command( + "delete", + "Delete an object from object storage. Prompts for confirmation unless --yes is set." + ) + .arguments("") + .option("--storage ", "Secondary storage name") + .option("--yes", "Skip the confirmation prompt") + .action(del as any) + .command( + "move", + "Move an object within the same storage (rename or relocate by key)." + ) + .arguments(" ") + .option("--storage ", "Secondary storage name") + .action(move as any) + .command( + "info", + "Show metadata (size, mime, last-modified) for an object." + ) + .arguments("") + .option("--json", "Output as JSON (for piping to jq)") + .option("--storage ", "Secondary storage name") + .action(info as any) + .command( + "preview", + "Preview the contents of an object (text/CSV). Use --bytes-from / --bytes-length to peek at a slice of binary files." + ) + .arguments("") + .option("--storage ", "Secondary storage name") + .option("--mime ", "Override the detected mime type (e.g. text/csv)") + .option("--bytes-from ", "Start offset in bytes") + .option("--bytes-length ", "Number of bytes to read") + .option("--csv-separator ", "CSV column separator (default ,)") + .option("--csv-header", "Treat the first CSV row as a header") + .action(preview as any); + +export default command; diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 65ccdf7c2a..7be08aae19 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -5168,6 +5168,7 @@ Once the flow has real content, **offer** to open the visual preview as a one-se After writing, tell the user which command fits what they want to do: - \`wmill flow preview \` — **default when iterating on a local flow.** Runs the local \`flow.yaml\` against local inline scripts without deploying. Add \`--remote\` to use deployed workspace scripts for PathScript steps instead of local files. +- \`wmill flow test-step \` — runs a single step of the local flow in isolation. Use when iterating on one module (rawscript / script / flow types) and you don't want to wait for upstream steps. Supports nested steps inside branchone/branchall/forloopflow/whileloopflow, plus the special \`preprocessor\` and \`failure\` modules by id. Pass step args with \`-d ''\`. - \`wmill flow run \` — runs the flow **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. - \`wmill generate-metadata\` — regenerate stale \`.lock\` and \`.script.yaml\` files. By default it scans **scripts, flows, and apps** across the workspace; pass \`--skip-flows --skip-apps\` (or run from a subdirectory) to limit the scope when you only care about the flow you edited. - \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". @@ -5184,6 +5185,12 @@ Only use \`sync push\` when: - The user explicitly asks to deploy, publish, push, or ship. - The preview has already validated the change and the user wants it in the workspace. +### Test a single step vs preview the whole flow + +Use \`flow test-step \` when the user is iterating on one module and the flow's upstream steps aren't part of what they're trying to validate. It runs only that step's runnable (rawscript: the inline script; script: the PathScript fetched from the workspace; flow: the subflow by path) and is much faster than running the whole flow when previous steps are slow or expensive. + +Use \`flow preview \` when steps depend on each other's outputs, when the user is validating the overall control flow, or when \`test-step\` doesn't apply (branchone, branchall, forloopflow, whileloopflow, identity, and AI agent steps cannot themselves be tested in isolation — for branchone/branchall/forloopflow/whileloopflow, the *contained* steps can, by passing the inner step's id). + ### After writing — offer to run, don't wait passively This is about **programmatic execution** (\`wmill flow preview -d ''\`), which actually runs the flow and has side effects. Visual preview (the \`preview\` skill) is offered separately — see "Visual preview" below. @@ -6842,6 +6849,10 @@ flow related commands - \`-d --data \` - Inputs specified as a JSON string or a file using @ or stdin using @-. - \`-s --silent\` - Do not output anything other then the final output. Useful for scripting. - \`--remote\` - Use deployed workspace scripts for PathScript steps instead of local files. +- \`flow test-step \` - Test a single step of a local flow in isolation. Resolves the step by id (including nested branchone/branchall/forloopflow/whileloopflow and the failure/preprocessor modules), runs only that step's runnable. Supported step types: rawscript, script (PathScript), flow (PathFlow). + - \`-d --data \` - Step inputs as a JSON string or a file using @ or stdin using @-. + - \`-s --silent\` - Do not output anything other then the final output. Useful for scripting. + - \`--json\` - Output the result as JSON (same as --silent) - \`flow new \` - create a new empty flow - \`--summary \` - flow summary - \`--description \` - flow description @@ -7057,6 +7068,42 @@ Validate Windmill flow, schedule, and trigger YAML files in a directory - \`--locks-required\` - Fail if scripts or flow inline scripts that need locks have no locks - \`-w, --watch\` - Watch for file changes and re-lint automatically +### object-storage + +**Alias:** \`s3\` + +**Subcommands:** + +- \`object-storage list\` - List configured object storages for the workspace (default + secondary). + - \`--json\` - Output as JSON (for piping to jq) +- \`object-storage files [prefix:string]\` - List files in an object storage. Optionally filter by prefix. + - \`--json\` - Output as JSON (for piping to jq) + - \`--max-keys \` - Page size (default 100) + - \`--marker \` - Pagination marker from a previous response + - \`--storage \` - Secondary storage name (omit for the workspace default) +- \`object-storage upload \` - Upload a local file to object storage at the given file key. + - \`--storage \` - Secondary storage name + - \`--content-type \` - Content-Type header to set on the object + - \`--content-disposition \` - Content-Disposition header to set on the object +- \`object-storage download [output_path:string]\` - Download an object to a local file (or stdout). Default output path is the basename of the file key in the current directory. + - \`--storage \` - Secondary storage name + - \`--stdout\` - Write file contents to stdout instead of a file +- \`object-storage delete \` - Delete an object from object storage. Prompts for confirmation unless --yes is set. + - \`--storage \` - Secondary storage name + - \`--yes\` - Skip the confirmation prompt +- \`object-storage move \` - Move an object within the same storage (rename or relocate by key). + - \`--storage \` - Secondary storage name +- \`object-storage info \` - Show metadata (size, mime, last-modified) for an object. + - \`--json\` - Output as JSON (for piping to jq) + - \`--storage \` - Secondary storage name +- \`object-storage preview \` - Preview the contents of an object (text/CSV). Use --bytes-from / --bytes-length to peek at a slice of binary files. + - \`--storage \` - Secondary storage name + - \`--mime \` - Override the detected mime type (e.g. text/csv) + - \`--bytes-from \` - Start offset in bytes + - \`--bytes-length \` - Number of bytes to read + - \`--csv-separator \` - CSV column separator (default ,) + - \`--csv-header\` - Treat the first CSV row as a header + ### protection-rules **Subcommands:** @@ -7393,6 +7440,26 @@ workspace related commands - \`--team-name \` - Slack team name - \`workspace disconnect-slack\` + + +# Object Storage CLI + +\`wmill object-storage\` (alias \`wmill s3\`) exposes the workspace's object storage (S3-compatible: AWS S3, MinIO, GCS, R2, Azure Blob) over the per-workspace \`/job_helpers/*\` endpoints. + +## Key concepts (not obvious from per-command --help) + +- **\`file_key\` is the path inside the bucket** (e.g. \`reports/2026-05/orders.csv\`), not a Windmill path. Do NOT pass \`u/...\` or \`f/...\` here — those are Windmill paths to scripts/flows/resources, unrelated to objects in the bucket. +- **Scope is the active workspace.** Object storage is configured per-workspace (default storage + optional secondary storages). Switching workspaces switches which bucket the commands target. +- **\`--storage \` targets a secondary storage** configured on the workspace. Omit it to use the workspace's default object storage. Use \`wmill object-storage list\` to discover configured storages. +- **\`preview\` vs \`download\`**: \`preview\` returns a peek (CSV first rows, text content, or a byte slice via \`--bytes-from\`/\`--bytes-length\`) without writing to disk. Use \`download\` when you want the full file on disk. + +## Choosing a subcommand + +- Look at what's there: \`wmill object-storage files [prefix]\` (alias \`ls\`) — paginated, use \`--marker\` to continue. +- Inspect one file: \`wmill object-storage info \` for size/mime/last-modified, \`wmill object-storage preview \` for content peek. +- Move data in: \`wmill object-storage upload \` — set \`--content-type\` if the receiver cares (e.g. \`text/csv\`). +- Move data out: \`wmill object-storage download [output_path]\` — \`--stdout\` to pipe. +- Reorganize: \`wmill object-storage move \` (same storage), \`wmill object-storage delete \` (interactive confirm unless \`--yes\`). `, "preview": `--- name: preview diff --git a/cli/src/main.ts b/cli/src/main.ts index 5cecd2aab1..d68dc82c77 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -52,6 +52,7 @@ import docs from "./commands/docs/docs.ts"; import config from "./commands/config/config.ts"; import datatable from "./commands/datatable/datatable.ts"; import ducklake from "./commands/ducklake/ducklake.ts"; +import objectStorage from "./commands/object-storage/object-storage.ts"; import { fetchVersion } from "./core/context.ts"; export { @@ -77,6 +78,7 @@ export { config, datatable, ducklake, + objectStorage, hubPull, pull, push, @@ -210,6 +212,7 @@ const command = new Command() .command("config", config) .command("datatable", datatable) .command("ducklake", ducklake) + .command("object-storage", objectStorage) .command("version --version", "Show version information") .action(async (opts: any) => { console.log("CLI version: " + VERSION); diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index 2e431b25b7..755bf8ced8 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -152,6 +152,10 @@ flow related commands - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-. - `-s --silent` - Do not output anything other then the final output. Useful for scripting. - `--remote` - Use deployed workspace scripts for PathScript steps instead of local files. +- `flow test-step ` - Test a single step of a local flow in isolation. Resolves the step by id (including nested branchone/branchall/forloopflow/whileloopflow and the failure/preprocessor modules), runs only that step's runnable. Supported step types: rawscript, script (PathScript), flow (PathFlow). + - `-d --data ` - Step inputs as a JSON string or a file using @ or stdin using @-. + - `-s --silent` - Do not output anything other then the final output. Useful for scripting. + - `--json` - Output the result as JSON (same as --silent) - `flow new ` - create a new empty flow - `--summary ` - flow summary - `--description ` - flow description @@ -367,6 +371,42 @@ Validate Windmill flow, schedule, and trigger YAML files in a directory - `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks - `-w, --watch` - Watch for file changes and re-lint automatically +### object-storage + +**Alias:** `s3` + +**Subcommands:** + +- `object-storage list` - List configured object storages for the workspace (default + secondary). + - `--json` - Output as JSON (for piping to jq) +- `object-storage files [prefix:string]` - List files in an object storage. Optionally filter by prefix. + - `--json` - Output as JSON (for piping to jq) + - `--max-keys ` - Page size (default 100) + - `--marker ` - Pagination marker from a previous response + - `--storage ` - Secondary storage name (omit for the workspace default) +- `object-storage upload ` - Upload a local file to object storage at the given file key. + - `--storage ` - Secondary storage name + - `--content-type ` - Content-Type header to set on the object + - `--content-disposition ` - Content-Disposition header to set on the object +- `object-storage download [output_path:string]` - Download an object to a local file (or stdout). Default output path is the basename of the file key in the current directory. + - `--storage ` - Secondary storage name + - `--stdout` - Write file contents to stdout instead of a file +- `object-storage delete ` - Delete an object from object storage. Prompts for confirmation unless --yes is set. + - `--storage ` - Secondary storage name + - `--yes` - Skip the confirmation prompt +- `object-storage move ` - Move an object within the same storage (rename or relocate by key). + - `--storage ` - Secondary storage name +- `object-storage info ` - Show metadata (size, mime, last-modified) for an object. + - `--json` - Output as JSON (for piping to jq) + - `--storage ` - Secondary storage name +- `object-storage preview ` - Preview the contents of an object (text/CSV). Use --bytes-from / --bytes-length to peek at a slice of binary files. + - `--storage ` - Secondary storage name + - `--mime ` - Override the detected mime type (e.g. text/csv) + - `--bytes-from ` - Start offset in bytes + - `--bytes-length ` - Number of bytes to read + - `--csv-separator ` - CSV column separator (default ,) + - `--csv-header` - Treat the first CSV row as a header + ### protection-rules **Subcommands:** @@ -703,3 +743,23 @@ workspace related commands - `--team-name ` - Slack team name - `workspace disconnect-slack` + + +# Object Storage CLI + +`wmill object-storage` (alias `wmill s3`) exposes the workspace's object storage (S3-compatible: AWS S3, MinIO, GCS, R2, Azure Blob) over the per-workspace `/job_helpers/*` endpoints. + +## Key concepts (not obvious from per-command --help) + +- **`file_key` is the path inside the bucket** (e.g. `reports/2026-05/orders.csv`), not a Windmill path. Do NOT pass `u/...` or `f/...` here — those are Windmill paths to scripts/flows/resources, unrelated to objects in the bucket. +- **Scope is the active workspace.** Object storage is configured per-workspace (default storage + optional secondary storages). Switching workspaces switches which bucket the commands target. +- **`--storage ` targets a secondary storage** configured on the workspace. Omit it to use the workspace's default object storage. Use `wmill object-storage list` to discover configured storages. +- **`preview` vs `download`**: `preview` returns a peek (CSV first rows, text content, or a byte slice via `--bytes-from`/`--bytes-length`) without writing to disk. Use `download` when you want the full file on disk. + +## Choosing a subcommand + +- Look at what's there: `wmill object-storage files [prefix]` (alias `ls`) — paginated, use `--marker` to continue. +- Inspect one file: `wmill object-storage info ` for size/mime/last-modified, `wmill object-storage preview ` for content peek. +- Move data in: `wmill object-storage upload ` — set `--content-type` if the receiver cares (e.g. `text/csv`). +- Move data out: `wmill object-storage download [output_path]` — `--stdout` to pipe. +- Reorganize: `wmill object-storage move ` (same storage), `wmill object-storage delete ` (interactive confirm unless `--yes`). diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 03ae52b41b..657408471d 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -2702,6 +2702,10 @@ flow related commands - \`-d --data \` - Inputs specified as a JSON string or a file using @ or stdin using @-. - \`-s --silent\` - Do not output anything other then the final output. Useful for scripting. - \`--remote\` - Use deployed workspace scripts for PathScript steps instead of local files. +- \`flow test-step \` - Test a single step of a local flow in isolation. Resolves the step by id (including nested branchone/branchall/forloopflow/whileloopflow and the failure/preprocessor modules), runs only that step's runnable. Supported step types: rawscript, script (PathScript), flow (PathFlow). + - \`-d --data \` - Step inputs as a JSON string or a file using @ or stdin using @-. + - \`-s --silent\` - Do not output anything other then the final output. Useful for scripting. + - \`--json\` - Output the result as JSON (same as --silent) - \`flow new \` - create a new empty flow - \`--summary \` - flow summary - \`--description \` - flow description @@ -2917,6 +2921,42 @@ Validate Windmill flow, schedule, and trigger YAML files in a directory - \`--locks-required\` - Fail if scripts or flow inline scripts that need locks have no locks - \`-w, --watch\` - Watch for file changes and re-lint automatically +### object-storage + +**Alias:** \`s3\` + +**Subcommands:** + +- \`object-storage list\` - List configured object storages for the workspace (default + secondary). + - \`--json\` - Output as JSON (for piping to jq) +- \`object-storage files [prefix:string]\` - List files in an object storage. Optionally filter by prefix. + - \`--json\` - Output as JSON (for piping to jq) + - \`--max-keys \` - Page size (default 100) + - \`--marker \` - Pagination marker from a previous response + - \`--storage \` - Secondary storage name (omit for the workspace default) +- \`object-storage upload \` - Upload a local file to object storage at the given file key. + - \`--storage \` - Secondary storage name + - \`--content-type \` - Content-Type header to set on the object + - \`--content-disposition \` - Content-Disposition header to set on the object +- \`object-storage download [output_path:string]\` - Download an object to a local file (or stdout). Default output path is the basename of the file key in the current directory. + - \`--storage \` - Secondary storage name + - \`--stdout\` - Write file contents to stdout instead of a file +- \`object-storage delete \` - Delete an object from object storage. Prompts for confirmation unless --yes is set. + - \`--storage \` - Secondary storage name + - \`--yes\` - Skip the confirmation prompt +- \`object-storage move \` - Move an object within the same storage (rename or relocate by key). + - \`--storage \` - Secondary storage name +- \`object-storage info \` - Show metadata (size, mime, last-modified) for an object. + - \`--json\` - Output as JSON (for piping to jq) + - \`--storage \` - Secondary storage name +- \`object-storage preview \` - Preview the contents of an object (text/CSV). Use --bytes-from / --bytes-length to peek at a slice of binary files. + - \`--storage \` - Secondary storage name + - \`--mime \` - Override the detected mime type (e.g. text/csv) + - \`--bytes-from \` - Start offset in bytes + - \`--bytes-length \` - Number of bytes to read + - \`--csv-separator \` - CSV column separator (default ,) + - \`--csv-header\` - Treat the first CSV row as a header + ### protection-rules **Subcommands:** @@ -3253,6 +3293,26 @@ workspace related commands - \`--team-name \` - Slack team name - \`workspace disconnect-slack\` + + +# Object Storage CLI + +\`wmill object-storage\` (alias \`wmill s3\`) exposes the workspace's object storage (S3-compatible: AWS S3, MinIO, GCS, R2, Azure Blob) over the per-workspace \`/job_helpers/*\` endpoints. + +## Key concepts (not obvious from per-command --help) + +- **\`file_key\` is the path inside the bucket** (e.g. \`reports/2026-05/orders.csv\`), not a Windmill path. Do NOT pass \`u/...\` or \`f/...\` here — those are Windmill paths to scripts/flows/resources, unrelated to objects in the bucket. +- **Scope is the active workspace.** Object storage is configured per-workspace (default storage + optional secondary storages). Switching workspaces switches which bucket the commands target. +- **\`--storage \` targets a secondary storage** configured on the workspace. Omit it to use the workspace's default object storage. Use \`wmill object-storage list\` to discover configured storages. +- **\`preview\` vs \`download\`**: \`preview\` returns a peek (CSV first rows, text content, or a byte slice via \`--bytes-from\`/\`--bytes-length\`) without writing to disk. Use \`download\` when you want the full file on disk. + +## Choosing a subcommand + +- Look at what's there: \`wmill object-storage files [prefix]\` (alias \`ls\`) — paginated, use \`--marker\` to continue. +- Inspect one file: \`wmill object-storage info \` for size/mime/last-modified, \`wmill object-storage preview \` for content peek. +- Move data in: \`wmill object-storage upload \` — set \`--content-type\` if the receiver cares (e.g. \`text/csv\`). +- Move data out: \`wmill object-storage download [output_path]\` — \`--stdout\` to pipe. +- Reorganize: \`wmill object-storage move \` (same storage), \`wmill object-storage delete \` (interactive confirm unless \`--yes\`). `; export const LANG_BASH = `# Bash diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 4f91714b81..85fc02746d 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -157,6 +157,10 @@ flow related commands - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-. - `-s --silent` - Do not output anything other then the final output. Useful for scripting. - `--remote` - Use deployed workspace scripts for PathScript steps instead of local files. +- `flow test-step ` - Test a single step of a local flow in isolation. Resolves the step by id (including nested branchone/branchall/forloopflow/whileloopflow and the failure/preprocessor modules), runs only that step's runnable. Supported step types: rawscript, script (PathScript), flow (PathFlow). + - `-d --data ` - Step inputs as a JSON string or a file using @ or stdin using @-. + - `-s --silent` - Do not output anything other then the final output. Useful for scripting. + - `--json` - Output the result as JSON (same as --silent) - `flow new ` - create a new empty flow - `--summary ` - flow summary - `--description ` - flow description @@ -372,6 +376,42 @@ Validate Windmill flow, schedule, and trigger YAML files in a directory - `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks - `-w, --watch` - Watch for file changes and re-lint automatically +### object-storage + +**Alias:** `s3` + +**Subcommands:** + +- `object-storage list` - List configured object storages for the workspace (default + secondary). + - `--json` - Output as JSON (for piping to jq) +- `object-storage files [prefix:string]` - List files in an object storage. Optionally filter by prefix. + - `--json` - Output as JSON (for piping to jq) + - `--max-keys ` - Page size (default 100) + - `--marker ` - Pagination marker from a previous response + - `--storage ` - Secondary storage name (omit for the workspace default) +- `object-storage upload ` - Upload a local file to object storage at the given file key. + - `--storage ` - Secondary storage name + - `--content-type ` - Content-Type header to set on the object + - `--content-disposition ` - Content-Disposition header to set on the object +- `object-storage download [output_path:string]` - Download an object to a local file (or stdout). Default output path is the basename of the file key in the current directory. + - `--storage ` - Secondary storage name + - `--stdout` - Write file contents to stdout instead of a file +- `object-storage delete ` - Delete an object from object storage. Prompts for confirmation unless --yes is set. + - `--storage ` - Secondary storage name + - `--yes` - Skip the confirmation prompt +- `object-storage move ` - Move an object within the same storage (rename or relocate by key). + - `--storage ` - Secondary storage name +- `object-storage info ` - Show metadata (size, mime, last-modified) for an object. + - `--json` - Output as JSON (for piping to jq) + - `--storage ` - Secondary storage name +- `object-storage preview ` - Preview the contents of an object (text/CSV). Use --bytes-from / --bytes-length to peek at a slice of binary files. + - `--storage ` - Secondary storage name + - `--mime ` - Override the detected mime type (e.g. text/csv) + - `--bytes-from ` - Start offset in bytes + - `--bytes-length ` - Number of bytes to read + - `--csv-separator ` - CSV column separator (default ,) + - `--csv-header` - Treat the first CSV row as a header + ### protection-rules **Subcommands:** @@ -708,3 +748,23 @@ workspace related commands - `--team-name ` - Slack team name - `workspace disconnect-slack` + + +# Object Storage CLI + +`wmill object-storage` (alias `wmill s3`) exposes the workspace's object storage (S3-compatible: AWS S3, MinIO, GCS, R2, Azure Blob) over the per-workspace `/job_helpers/*` endpoints. + +## Key concepts (not obvious from per-command --help) + +- **`file_key` is the path inside the bucket** (e.g. `reports/2026-05/orders.csv`), not a Windmill path. Do NOT pass `u/...` or `f/...` here — those are Windmill paths to scripts/flows/resources, unrelated to objects in the bucket. +- **Scope is the active workspace.** Object storage is configured per-workspace (default storage + optional secondary storages). Switching workspaces switches which bucket the commands target. +- **`--storage ` targets a secondary storage** configured on the workspace. Omit it to use the workspace's default object storage. Use `wmill object-storage list` to discover configured storages. +- **`preview` vs `download`**: `preview` returns a peek (CSV first rows, text content, or a byte slice via `--bytes-from`/`--bytes-length`) without writing to disk. Use `download` when you want the full file on disk. + +## Choosing a subcommand + +- Look at what's there: `wmill object-storage files [prefix]` (alias `ls`) — paginated, use `--marker` to continue. +- Inspect one file: `wmill object-storage info ` for size/mime/last-modified, `wmill object-storage preview ` for content peek. +- Move data in: `wmill object-storage upload ` — set `--content-type` if the receiver cares (e.g. `text/csv`). +- Move data out: `wmill object-storage download [output_path]` — `--stdout` to pipe. +- Reorganize: `wmill object-storage move ` (same storage), `wmill object-storage delete ` (interactive confirm unless `--yes`). diff --git a/system_prompts/auto-generated/skills/write-flow/SKILL.md b/system_prompts/auto-generated/skills/write-flow/SKILL.md index 3938d2a30f..23636a3166 100644 --- a/system_prompts/auto-generated/skills/write-flow/SKILL.md +++ b/system_prompts/auto-generated/skills/write-flow/SKILL.md @@ -47,6 +47,7 @@ Once the flow has real content, **offer** to open the visual preview as a one-se After writing, tell the user which command fits what they want to do: - `wmill flow preview ` — **default when iterating on a local flow.** Runs the local `flow.yaml` against local inline scripts without deploying. Add `--remote` to use deployed workspace scripts for PathScript steps instead of local files. +- `wmill flow test-step ` — runs a single step of the local flow in isolation. Use when iterating on one module (rawscript / script / flow types) and you don't want to wait for upstream steps. Supports nested steps inside branchone/branchall/forloopflow/whileloopflow, plus the special `preprocessor` and `failure` modules by id. Pass step args with `-d ''`. - `wmill flow run ` — runs the flow **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. - `wmill generate-metadata` — regenerate stale `.lock` and `.script.yaml` files. By default it scans **scripts, flows, and apps** across the workspace; pass `--skip-flows --skip-apps` (or run from a subdirectory) to limit the scope when you only care about the flow you edited. - `wmill sync push` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". @@ -63,6 +64,12 @@ Only use `sync push` when: - The user explicitly asks to deploy, publish, push, or ship. - The preview has already validated the change and the user wants it in the workspace. +### Test a single step vs preview the whole flow + +Use `flow test-step ` when the user is iterating on one module and the flow's upstream steps aren't part of what they're trying to validate. It runs only that step's runnable (rawscript: the inline script; script: the PathScript fetched from the workspace; flow: the subflow by path) and is much faster than running the whole flow when previous steps are slow or expensive. + +Use `flow preview ` when steps depend on each other's outputs, when the user is validating the overall control flow, or when `test-step` doesn't apply (branchone, branchall, forloopflow, whileloopflow, identity, and AI agent steps cannot themselves be tested in isolation — for branchone/branchall/forloopflow/whileloopflow, the *contained* steps can, by passing the inner step's id). + ### After writing — offer to run, don't wait passively This is about **programmatic execution** (`wmill flow preview -d ''`), which actually runs the flow and has side effects. Visual preview (the `preview` skill) is offered separately — see "Visual preview" below. diff --git a/system_prompts/base/flow-cli.md b/system_prompts/base/flow-cli.md index 2ce85e5166..3f814d12c0 100644 --- a/system_prompts/base/flow-cli.md +++ b/system_prompts/base/flow-cli.md @@ -42,6 +42,7 @@ Once the flow has real content, **offer** to open the visual preview as a one-se After writing, tell the user which command fits what they want to do: - `wmill flow preview ` — **default when iterating on a local flow.** Runs the local `flow.yaml` against local inline scripts without deploying. Add `--remote` to use deployed workspace scripts for PathScript steps instead of local files. +- `wmill flow test-step ` — runs a single step of the local flow in isolation. Use when iterating on one module (rawscript / script / flow types) and you don't want to wait for upstream steps. Supports nested steps inside branchone/branchall/forloopflow/whileloopflow, plus the special `preprocessor` and `failure` modules by id. Pass step args with `-d ''`. - `wmill flow run ` — runs the flow **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. - `wmill generate-metadata` — regenerate stale `.lock` and `.script.yaml` files. By default it scans **scripts, flows, and apps** across the workspace; pass `--skip-flows --skip-apps` (or run from a subdirectory) to limit the scope when you only care about the flow you edited. - `wmill sync push` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". @@ -58,6 +59,12 @@ Only use `sync push` when: - The user explicitly asks to deploy, publish, push, or ship. - The preview has already validated the change and the user wants it in the workspace. +### Test a single step vs preview the whole flow + +Use `flow test-step ` when the user is iterating on one module and the flow's upstream steps aren't part of what they're trying to validate. It runs only that step's runnable (rawscript: the inline script; script: the PathScript fetched from the workspace; flow: the subflow by path) and is much faster than running the whole flow when previous steps are slow or expensive. + +Use `flow preview ` when steps depend on each other's outputs, when the user is validating the overall control flow, or when `test-step` doesn't apply (branchone, branchall, forloopflow, whileloopflow, identity, and AI agent steps cannot themselves be tested in isolation — for branchone/branchall/forloopflow/whileloopflow, the *contained* steps can, by passing the inner step's id). + ### After writing — offer to run, don't wait passively This is about **programmatic execution** (`wmill flow preview -d ''`), which actually runs the flow and has side effects. Visual preview (the `preview` skill) is offered separately — see "Visual preview" below. diff --git a/system_prompts/base/object-storage-cli.md b/system_prompts/base/object-storage-cli.md new file mode 100644 index 0000000000..c8ad5d5918 --- /dev/null +++ b/system_prompts/base/object-storage-cli.md @@ -0,0 +1,18 @@ +# Object Storage CLI + +`wmill object-storage` (alias `wmill s3`) exposes the workspace's object storage (S3-compatible: AWS S3, MinIO, GCS, R2, Azure Blob) over the per-workspace `/job_helpers/*` endpoints. + +## Key concepts (not obvious from per-command --help) + +- **`file_key` is the path inside the bucket** (e.g. `reports/2026-05/orders.csv`), not a Windmill path. Do NOT pass `u/...` or `f/...` here — those are Windmill paths to scripts/flows/resources, unrelated to objects in the bucket. +- **Scope is the active workspace.** Object storage is configured per-workspace (default storage + optional secondary storages). Switching workspaces switches which bucket the commands target. +- **`--storage ` targets a secondary storage** configured on the workspace. Omit it to use the workspace's default object storage. Use `wmill object-storage list` to discover configured storages. +- **`preview` vs `download`**: `preview` returns a peek (CSV first rows, text content, or a byte slice via `--bytes-from`/`--bytes-length`) without writing to disk. Use `download` when you want the full file on disk. + +## Choosing a subcommand + +- Look at what's there: `wmill object-storage files [prefix]` (alias `ls`) — paginated, use `--marker` to continue. +- Inspect one file: `wmill object-storage info ` for size/mime/last-modified, `wmill object-storage preview ` for content peek. +- Move data in: `wmill object-storage upload ` — set `--content-type` if the receiver cares (e.g. `text/csv`). +- Move data out: `wmill object-storage download [output_path]` — `--stdout` to pipe. +- Reorganize: `wmill object-storage move ` (same storage), `wmill object-storage delete ` (interactive confirm unless `--yes`). diff --git a/system_prompts/generate.py b/system_prompts/generate.py index eb4dd96462..0c03e47fb5 100644 --- a/system_prompts/generate.py +++ b/system_prompts/generate.py @@ -2312,6 +2312,13 @@ def main(): print("Extracting CLI commands...") cli_data = extract_cli_commands() cli_commands = generate_cli_commands_markdown(cli_data) + # Append hand-written CLI guidance covering bits that aren't obvious from + # the auto-generated per-command --help (file_key semantics, --storage, + # workspace scope). The cli-commands skill is the entry point agents read + # to learn about `wmill`, so non-obvious usage notes belong here. + object_storage_cli = read_markdown_file(base_dir / "object-storage-cli.md") + if object_storage_cli: + cli_commands = f"{cli_commands}\n\n{object_storage_cli}" OUTPUT_CLI_DIR.mkdir(parents=True, exist_ok=True) (OUTPUT_CLI_DIR / "cli-commands.md").write_text(cli_commands) print(f" Found {len(cli_data['commands'])} commands, {len(cli_data['global_options'])} global options")