mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 08:02:18 +00:00
* feat(pipeline): AI chat tools to build pipeline nodes with diff/approval Add a data-pipeline AI chat experience modeled on the flow editor and surfaced through the dev-gated global chat (no new chat panel). The /pipeline editor registers PipelineAIChatHelpers on the AIChatManager; while it is open the global mode layers pipeline tools, a pipeline prompt section, and the helpers on top of the full global tool set (behavior is unchanged when no pipeline editor is open). New tools (frontend/src/lib/components/copilot/chat/pipeline/core.ts): - get_pipeline_graph / read_pipeline_node — read the live graph and bodies - build_pipeline_node / edit_pipeline_node — stage changes as AI-pending drafts - remove_pipeline_node — drop a staged proposal - test_pipeline_node — preview-run a node (requires confirmation) Tools never deploy: they stage drafts flagged aiPending, rendered on the canvas with an accent ring and reviewed via Accept all / Reject all (the flow editor's GlobalReviewButtons). Accept commits the drafts; Reject reverts to a pre-AI snapshot, preserving earlier accepted drafts. Auto-accept is gated on the chat autonomy mode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(pipeline): teach the global/session chat to author data pipelines Without an open /pipeline editor the session chat had no pipeline concept, so "create a data pipeline" loaded flow instructions and built a flow. Add a first-class pipeline authoring path: - system_prompts/base/pipeline-base.md — what a data pipeline is (a DAG of annotated scripts wired by storage assets, NOT a flow) and how to author the // pipeline / // on / // materialize annotations; wired through generate.py as getPipelinePrompt() (regenerated prompts.ts/index.ts). - global/core.ts — new get_instructions subject "pipeline", and a global-prompt rule disambiguating data pipelines from flows so the model routes correctly. - ai_evals/cases/global.yaml — two global cases (single node, two-node chain) asserting pipeline-annotated script drafts and forbidding write_flow, guarding the pipeline-vs-flow conflation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(pipeline): show & build pipelines in the AI session preview Add a 'pipeline' session preview target so the session AI can show the data-pipeline graph for a folder and build nodes in-pane: - open_preview now accepts kind="pipeline" (path = folder); SessionTarget / EDITOR_TARGET_KINDS widen accordingly. The slot/codec load model stays flow|script|raw_app — pipeline bypasses it with its own fetch/draft state. - New PipelineEditorView.svelte mounts in the session pane: fetches the folder graph, overlays AI drafts, renders AssetGraphCanvas + the Accept/Reject review buttons, and registers PipelineAIChatHelpers on the *session-scoped* manager (via getAiChatManager) so build_pipeline_node / edit_pipeline_node + the diff/approval work inside the session too. - System prompt nudges the model to open the pipeline preview and use the staging tools while building. Verified end-to-end with a real model: the session AI called open_preview, the graph mounted in the side panel, then build_pipeline_node staged a node on the session canvas with its schedule trigger and ducklake output. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(pipeline): share the AI editor logic between route page and session Consolidate the duplicated data-pipeline AI logic onto a single shared layer so the route editor and the in-session preview behave identically and the session gains the full code editor. - New pipelineAiHelpers.ts: createPipelineAiHelpers(deps) owns the propose/edit/ remove/accept/reject/test staging + the per-turn snapshot bookkeeping that powers Reject. Callers inject accessors for their own draft Map and graph. - Route page (/pipeline/[folder]) drops its ~250-line inline AI-helper block and wires the shared factory via deps (folder/workspace/graph/drafts + focus, ensureEditable, run-started). Its shell — persistence, navigation guard, activity, cascade, trigger drawers — is untouched. - Session PipelineEditorView uses the same factory and now renders the real AssetGraphDetailsPane (code editor + live overlays + test), so a node built in a session opens with its source, matching the route editor. Verified: route page hydrates/renders drafts unchanged; in a session the AI opened the pipeline preview, built a node, and its code showed in the details pane. check:fast clean, 197 unit tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(pipeline): externalize editor state into PipelineEditorState (step 1) Introduce PipelineEditorState — the data-pipeline analogue of the flow editor's flowStore. It owns the draft Map, the live editor overlays, and the selection, with callback-safe methods (handleDraftPersist / handleAnnotationsChange / … ), so a single editor can be rendered by both the route page and the session. This commit lands the store and points the in-session PipelineEditorView at it (no behaviour change — the session already had these inline). Next steps move the route page onto the store and a shared <PipelineGraphEditor>. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(pipeline): point the route editor at PipelineEditorState (step 1) Move the route page's draft Map, live editor overlays, selection, and the draft-persist / live-change handlers onto the shared PipelineEditorState (`pe`), referencing them as `pe.*` in place. No behaviour change — persistence, graph resolution, run dispatch, AI staging, and deploy all stay on the page and now read/write the externalized state. This is the data-pipeline analogue of the flow editor's flowStore: the route page and the in-session preview now share one source of editor truth, setting up the shared <PipelineGraphEditor> in the next steps. Verified: the page hydrates its DB draft, renders the overlay graph, the toolbar counts (Save all (N)) track pe.drafts, and selecting a node opens it in the details pane. check:fast clean, 84 unit tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(pipeline): render the route editor via shared PipelineGraphEditor (step 2) Extract the canvas + details-pane editor body into PipelineGraphEditor.svelte, the data-pipeline analogue of FlowBuilder. The route page now delegates its Splitpanes block to it, passing the externalized PipelineEditorState plus its run/cascade/trigger/deploy callbacks; the component owns pane sizing, selection/details-open derivation, and the canvas+details rendering. Root-caused the earlier ts2769 "$props() No overload" to a prop named `state` colliding with the `$state` rune (`let x = $state(...)` parsed as a store auto-subscription on the prop) — the prop is now `editor`. Net: the route page sheds ~310 lines of template/state; behaviour preserved. Verified: the page hydrates its DB draft, renders the graph, opens the draft in the details pane (live code editor + Test), pane sizing works. check:fast clean, 24 pipeline tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(pipeline): move draft autosave into PipelineGraphEditor (step 3) Fold the per-user `data_pipeline` DraftService bundle autosave (hydrate + debounced persist + localStorage crash mirror) into PipelineGraphEditor, gated by a `persistDrafts` prop — FlowBuilder's parameterized-autosave shape. The route page passes `persistDrafts` + `folder` and reads `editor.loadedFromDbDraft` for its AutosaveIndicator; the in-session preview will leave persistence off. Also restores the `untrack(...)` wrapping on the pane-sizing $effect (dropped when the editor body was extracted in step 2). Without it the Pane `bind:size` feedback loops the effect and pegs the main thread when the details pane is closed — a latent hang in the step-2 commit. check:fast clean, 24 pipeline tests pass. Note: browser revalidation was not possible this session (the Playwright MCP browser was reset); the autosave is a verbatim port and the untrack fix is the original working form. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(pipeline): render the session preview via shared PipelineGraphEditor (step 4) Point the in-session PipelineEditorView at the shared PipelineGraphEditor instead of its own inline canvas + details pane. The session now renders the exact same editor body as the route page — gaining the full details/code pane — while opting out of persistence (persistDrafts=false) and the run/cascade/trigger/bounded affordances (their callbacks are omitted, so those controls hide). Building nodes + the Accept/Reject diff still work via the AI helpers. Also fixes issues surfaced by a full `svelte-check` while wiring this up: - PipelineGraphEditor: edit mode opened the details pane unconditionally (a step-2 regression); restored the route's "open only on selection/draft" behaviour. - Route page passed an `isOperator` prop the component doesn't accept (step-2; caught only by full check, not check:fast). - SessionItemNotFound: narrow its `kind` to exclude `pipeline` (pipeline targets never slot-load, so they can't 404 through it) — closes the SessionTarget-widen fallout. - PipelineEditorView: cast the resolveGraph base to AssetGraphResponse. Full `svelte-check` now clean across all pipeline/session files; 137 unit tests pass. (Browser revalidation still pending — Playwright MCP was unavailable this session; see the smoke-test note on the PR.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(pipeline): stop an infinite microtask loop when persisting a no-output draft handleDraftPersist short-circuits when the open draft's content + inferred writes are unchanged. The writes check compared `d.outputAssets?.length === writes.length`, but a no-output draft has `outputAssets: undefined` (so `?.length` is `undefined`) while the details pane infers an empty `writes: []` (length 0). `undefined === 0` is false, so it never short-circuited: every persist re-wrote the drafts Map with an equivalent object, which gave `activeDraft.script` a new identity → the pane re-emitted its overlays → the graph re-derived → persist fired again. A self- sustaining microtask loop that pegged the renderer and froze the tab on any pipeline carrying a no-output draft (e.g. hydrating one from the saved data_pipeline draft on load). It hangs rather than throwing effect_update_depth_ exceeded because it cycles across microtasks, not within one reactive flush. Fix: coalesce the undefined length to 0 so "no outputs" compares equal to an empty inferred-writes list. Adds pipelineEditorState.test.ts covering the idempotency (fails without the fix) plus the change/no-change cases. Root-caused by instrumenting the reactive churn: every iteration reassigned drafts/liveContent/liveBodyAssets/liveAnnotations/displayGraph with identical values — pure reference churn off the drafts re-write. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ai-chat): make the agent open the pipeline editor before building nodes In a session, the GLOBAL system prompt only *advised* opening the pipeline preview ("show its graph with open_preview ... prefer those tools once it is open"), so the agent routinely skipped it: on a plain "build a data pipeline" request it reached for write_script and staged plain script drafts, and the canvas editor never opened. build_pipeline_node / edit_pipeline_node are only registered once the preview is open, so skipping open_preview also loses the canvas-staged Accept/Reject diff-approval flow entirely. Make the guidance imperative: open_preview(kind="pipeline", path=<folder>) is the FIRST step before creating any node (an empty or not-yet-created folder is fine — create_folder first if needed), and pipeline nodes go through build_pipeline_node / edit_pipeline_node, never write_script. This also clears the agent's "the folder might not exist" hesitation that pushed it toward write_script. Verified live (same plain prompt, before/after): before it used write_script with no editor; after, the agent opens the editor first and stages a canvas-highlighted node with Accept all / Reject all. The guidance is gated on previewTools (session-only), so it doesn't affect the non-preview global eval cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(pipeline): preserve in-session pipeline drafts across editor hide/show The session preview's PipelineEditorState lived in the PipelineEditorView component with persistDrafts=false. Hiding the editor sets editorVisible=false, which makes `hasEditor` false and the `{#if hasEditor}` block unmount the view — discarding its component-local store. Showing it again remounted a fresh, empty one, so the pipeline the AI had built in the session vanished. Move the PipelineEditorState onto the per-session SessionRuntime (like the flow / script / raw_app editors, which already host their state there and take {runtime}), so it survives the pane unmount on hide and across session switches. The runtime is keyed by session id and only dropped on session deletion. Because the instance is now reused, guard against a retarget to a different folder: PipelineEditorView resets the state when `path` changes to a new folder (a same-folder remount keeps the drafts). Adds `folder` + `reset()` to the store. Verified: build a node in a session → Close editor → Show editor → the staged node, its wiring, the details-pane code, and Accept/Reject all re-appear. Full svelte-check clean; 139 pipeline tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(pipeline-ai): clearer diff + persistent review banner on the canvas The AI review affordance had two problems on the pipeline canvas: - The floating Accept-all / Reject-all bar sat bottom-center, where it collided with the minimap once the canvas narrowed on node selection — reading as "the buttons vanished when I select a node". - Every staged draft rendered with the same blue ring, so it wasn't clear what the review would actually change (a plain manual draft looked the same as an AI proposal). Replace the floating bar with a top-left review banner (z-30, clear of the controls and minimap) that stays put regardless of selection and spells out the pending counts. Color the diff per node: a proposal that adds a node that isn't deployed rings green with a "new" chip; one that edits an already-deployed node rings amber with an "edited" chip. Plain manual drafts keep the neutral gray dashed border, so only the green/amber nodes read as part of the Accept/Reject set. aiPendingKind is resolved in resolveGraph (deployed runnable present → modified, else added) and forwarded through the canvas to the node. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(pipeline-ai): persist in-session pipeline proposals across reload/switch Staged AI proposals lived only in the per-session runtime's in-memory PipelineEditorState (persistDrafts=false), so a page reload — and an LRU-evicted runtime on session switch — dropped them, leaving the canvas and the Accept/Reject review empty even though the chat still showed the nodes as staged. Enable the same per-folder DB-draft persistence the route page uses for the in-session editor. To keep hide/show cheap and race-free, hydration is now gated per editor instance (PipelineEditorState.hydratedFromDb) rather than per component mount: the runtime-hosted instance hydrates ONCE when fresh (reload / evicted runtime) and then keeps its in-memory drafts across the editor pane unmounting on hide — re-reading the DB on every remount would race a not-yet-flushed autosave and drop a just-staged draft. A folder retarget resets the flag so the new folder re-hydrates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(pipeline-ai): make Reject all work for rehydrated proposals rejectAll only reverted paths tracked in the in-memory aiSnapshots map, which is rebuilt empty on each editor mount. After a reload (or session switch into a fresh runtime) the proposals are restored from the persisted draft but have no snapshot, so Reject all was a no-op on exactly the nodes it should discard. Sweep any still-pending draft without a snapshot and discard it (revertPath with no snapshot deletes the path; for an edit of a deployed node that correctly falls back to the deployed body). Adds unit coverage for accept/reject including the no-snapshot case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(pipeline-ai): keep proposals visible while the graph reloads on switch The session editor pane is LRU-capped (MAX_WARM_EDITORS), so returning to a session whose pane was evicted remounts PipelineEditorView with a fresh graphRes resource (loading=true, current=undefined). The deployed-graph loading spinner gated the whole canvas, so the staged proposals and the Accept/Reject review banner vanished until the re-fetch resolved — read as "the proposal disappears when I switch sessions". Only show the loading/error placeholder when there are no drafts to display. When the runtime already holds staged drafts, render the editor immediately: resolveGraph overlays them on an empty base so the proposals + banner stay visible, and the deployed nodes fill in when the fetch completes. Verified with a 4s-delayed graph fetch — proposals render through the load with no spinner. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(pipeline-ai): apply AI node edits directly as drafts, no approve/reject The canvas-level Accept all / Reject all review (aiPending proposals, the green/amber diff ring + "new"/"edited" chips, and the review banner) didn't fit the pipeline editor. Match the flow/script editor instead: build/edit apply directly as ordinary unsaved drafts on the canvas, which the user then deploys — there is no separate approval step. Removed across the surface: - aiPending / aiPendingKind on the runnable node + resolveGraph seeding + canvas forwarding; AI-built nodes now render with the existing plain unsaved-draft dashed styling. - the review banner, count derivations, and hasAiPending/onAccept/onReject props from PipelineGraphEditor and both consumers (route page + session view). - acceptAll/rejectAll/hasPending and the per-turn snapshot bookkeeping from the shared helpers; removeProposedNode now just discards the unsaved draft at a path (undo a build). acceptAllProposals/rejectAllProposals/ hasPendingProposals dropped from the PipelineAIChatHelpers interface and the manager's auto-accept hook. - accept/reject language from the tool descriptions, return messages, and the system-prompt section. Tests updated; pipeline + AssetGraph suites pass (142). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(drafts-diff): support data_pipeline diffs + fix blank empty-summary row Two issues in the session "Drafts" diff drawer (DraftDiffDrawer): - Clicking a `data_pipeline` bundle row threw "Draft diff not supported for kind data_pipeline" (utils_draft_deploy.ts) — there was no handler for the kind, so it fell to the OVERLAY_GETTERS lookup and errored. The bundle has no deployed counterpart (each node deploys individually as a script), so diff it node-by-node: surface each node's draft body keyed by path, folding in the deployed body as the "before" when a node edits a deployed script. - A draft row whose summary is an empty string (e.g. the app draft) rendered with no title at all: WorkspaceItemRow's single-line branch used `summary ?? secondary`, and `??` doesn't treat '' as absent, so it showed the empty summary instead of the path. Use `||` so an empty summary falls back to the path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(drafts-diff): explode data_pipeline bundle into per-node subitems A data_pipeline draft is a bundle of node-script drafts, so a single row diffed the whole thing as one blob. Explode it in DraftDiffDrawer into one script row per node, nested under the bundle's `…/data_pipeline` folder so they read as the pipeline's subitems — each with its own path and a proper script Content/Metadata code diff. The node's draft body is the "after"; its deployed body (when the node is already deployed) is the "before", so edits show as line diffs and new nodes as added. A single bundle row (via the getDraftDiffValues data_pipeline fallback) is kept only for the case where the bundle can't be read. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(pipeline-ai): simplify — drop vestigial approve/reject scaffolding & redundant field Review pass over the PR, removing complexity left from the approve/reject removal and the shared-component refactor (all behavior-preserving): - Inline the `acceptPendingEdits` pass-through into `acceptPendingFlowEdits` and revert the now-inert `autoAcceptEditsAvailable` GLOBAL+pipeline widening (pipeline edits are direct drafts — nothing to auto-accept). - Fix the global system prompt: pipeline tools "apply directly as unsaved drafts (no accept/reject)", not "proposals the user Accepts or Rejects". - Collapse the redundant `outputAsset` (singular) into `outputAssets`, removing a whole resolveGraph fallback tier; simplify propose/editNode. - Drop the single-field `PipelineAiHelpersHandle` wrapper (callers just destructured `{ helpers }`); inline the misleading `isoNow()` helper. - Remove the now-unreachable `data_pipeline` branch in getDraftDiffValues (the drafts drawer explodes bundles per-node; an unreadable bundle is skipped) and the "Step N consolidation" drafting narration. - Un-export internal-only types; reuse `storageKey`; refresh stale comments that still referenced proposals / the review banner / diff-approval. svelte-check clean; 141 unit tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(pipeline): tooltip clarifying the Create/Save button deploys The accent button in the asset-graph details pane ("Create" for a new script, "Save" for an existing one) is really a deploy, but had no tooltip explaining that. Add a title — "Deploy this new script to the workspace" / "Deploy your changes to this script" — keeping the create-vs-update label distinction while making clear both deploy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(pipeline-ai): document the `materialize` annotation in the pipeline prompt The model invented "materialize run" because the prompt only mentioned `// materialize <uri>` in passing. Spell out what it is in both the in-app pipeline prompt (getPipelinePromptSection) and the base prompt (pipeline-base.md, regenerated): a MANAGED output where the runtime writes the table around a single SELECT (no manual CREATE/INSERT); replace (default) vs `append` vs `key=<col>` strategies; `manual` to opt out (track-only); and its pairing with `// partitioned …` (runs once per partition, `{partition}` token substituted at run time). Explicitly: materialize is an output declaration, not a command — there is no "materialize run". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(pipeline-ai): trigger drawers in the AI session preview Bring the route page's native-trigger affordances to the in-session pipeline editor by reusing the shared <PipelineTriggerEditors> (no duplication of the drawer UI). Clicking a "Schedule · Missing — no trigger row" node (or edit/delete on an attached trigger, webhook, data-upload) now opens the same drawers the full editor uses, instead of doing nothing. Draft nodes get the same "save the script first" guard (a trigger row needs a deployed script). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(pipeline-ai): run buttons + live run state in the AI session preview Wire the per-node Run button and live run-state badges into the in-session pipeline editor, reusing the shared folder-scoped job poll (useActiveRunnableIds) the route page uses — node badges, the event log, and the zero-latency "running" hint all come from it. The session runs one node at a time (preview for an unsaved draft, the deployed version otherwise), skipping the route page's cascade/deploy-queue machinery the AI-session UX doesn't need. Verified: a node's Run button dispatches a job and the badge updates live from the poll. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(pipeline): label the node deploy button "Deploy" (was Create/Save) Users read "Create" and asked whether it deploys. It does — and the main script editor's DeployButton already says "Deploy", so this is the consistent term. Use "Deploy" for both the new-script and existing-script cases; the new-vs-changes nuance stays in the button's tooltip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(home): surface data pipelines as units, including bundle-phase drafts Treat a data pipeline as one home entry instead of scattering its member scripts: - The home "Pipeline · f/<folder>" entry now also covers bundle-phase pipelines — a folder that so far only exists as a `data_pipeline` draft — not just deployed ones, so a pipeline shows up the moment its first node is drafted (union listPipelineFolders + data_pipeline draft folders). - Pipeline-member scripts (`auto_kind='pipeline'`) are filtered out of the individual scripts list; they're represented by their pipeline's entry. - Tree view injects pipeline folders so they (and their "Pipeline" entry) still appear when their only scripts are hidden members or they have none deployed yet. Verified in both list and tree view: app_groups (deployed member folded) and a draft-only nyc_transit both show as pipelines; the member script no longer lists individually. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(scripts): compute auto_kind for draft-only pipeline nodes A never-deployed pipeline node (a script draft starting with `// pipeline`) had no script row, so list_scripts synthesized it with `auto_kind: None` — and the home page therefore couldn't tell it was a pipeline member, listing it individually instead of folding it into its pipeline. Parse the draft content the same way the create path does (`parse_pipeline_annotations(...).in_pipeline`) and set `auto_kind = "pipeline"` on the synthesized draft-only row, so draft nodes fold into their pipeline like deployed members. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(search): hide pipeline-member scripts from global search The Ctrl+k global search listed pipeline-member scripts (`auto_kind='pipeline'`) individually. Filter them out — they're reached through their pipeline, matching the home page. Deployed members carry auto_kind from the script row; draft-only members now do too (computed from draft content in list_scripts), so both are excluded here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(pipeline): address PR review findings Session run dispatch (the one real bug): - runNode now passes `_wmill_skip_asset_dispatch: true` for a single-node run of a deployed node unless the user chose "run + downstream" (cascade) — previously a single Run could fan out to downstream deployed scripts via the backend asset dispatcher and fire side-effecting production runs. - onRunProducer guards `kind === 'script'`; onTestStateChange only clears the run hint for the script the pane finished (not a different in-flight node); clear the hint on folder retarget; gate the background poll on isActiveSession so hidden warm panes don't poll; note the PipelineTriggerEditors workspace coupling. Home page pipeline surfacing: - Fold pipeline-member folders into `pipelineFolders` (captured in loadScripts) so a members-only / draft-only-`// pipeline` folder still shows its pipeline entry instead of vanishing; and don't render the empty-state when only pipelines remain (they aren't part of the text filter). - Insert injected tree folders in name order instead of prepending. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(pipeline-ai): make clear `// materialize` is DuckDB + DuckLake only The model put `// materialize` on a python3 node, which deploy rejects ("only supported for DuckDB scripts"). The prompt only implied SQL ("write the body as a single SELECT") without stating the hard constraint. Spell it out in both the in-app prompt and pipeline-base.md: `// materialize` is DuckDB-only and its target must be a DuckLake table; for python3/bun/postgresql nodes, write the output via the SDK instead and let it be inferred — reach for duckdb when a node should materialize a DuckLake table. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(pipeline-ai): fix stale comment — session now wires run + trigger affordances Addresses review: the comment still claimed the session 'opts out of the run/cascade/trigger/bounded affordances', but run buttons + trigger drawers were wired in. Describe the current state (wires run + triggers; omits only cascade/bounded/add-script). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(pipeline): address Codex review — test_pipeline_node dispatch + tree search - [P1] testNode (the test_pipeline_node tool) ran a deployed node via runScriptByPath without `_wmill_skip_asset_dispatch`, so previewing one node could fan out to downstream deployed subscribers and run side-effecting scripts. Add the skip flag (test is always single-node) + a regression test. - [P2] Home tree view injected pipeline folders — and rendered their Pipeline row — even during a text search, surfacing unrelated pipelines. Gate both the TreeViewRoot injection and TreeView's hasPipeline on `!isSearching`, matching the list view which hides pipeline rows on a query. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(pipeline-ai): keep the pipeline prompt after update_user_instructions rebuildGlobalSystemMessage (called by the update_user_instructions tool) rebuilt only the base Global prompt, dropping the pipeline-editor section that configureGlobalMode appends. So after the chat remembered an instruction, the next GLOBAL turn lost the active /pipeline/<folder> context + direct-draft/ materialize guidance while pipeline tools stayed registered. Re-append the pipeline section here when a pipeline editor is registered. Addresses Codex review [P2]. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(home): gate pipeline entries by kind/archived/owner filters Codex review [P2]: pipeline rows/folders rendered independently of the item filters, so a pipeline still showed under the Flows/Apps tabs, in the archived view, and outside a selected owner. Add `visiblePipelineFolders` applying the same gates the items get (kind ∈ {all, script}, not archived, owner-prefix match) and route the list rows, tree injection, and empty-state check through it. Pipelines are always `f/<folder>`, so the user-folder toggle and kind=script keep including them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(pipeline): address review — route folder-switch state, AI node guards, diff identity claude[bot] [P1]: the route page's in-app folder switcher navigates same-route (no remount), but nothing reset PipelineEditorState — so folder A's drafts displayed under B and autosave persisted them into B's bundle, and B never hydrated. Reset pe on folder change (mirror the session retarget), and guard the shared hydrateDrafts against a stale folder result landing after a retarget. codex/claude [P2]: build_pipeline_node (proposeNode) only checked drafts.has — now rejects a path outside the open folder and one colliding with an existing deployed node (model should edit_pipeline_node). + 3 regression tests. codex/claude [P2]: exploded pipeline-node diff rows shared `script/<path>` with a standalone script draft at the same path, colliding in the {#each} key + value cache. Add an explicit unique `key` (the distinct bundle-nested path) on DiffRow; pipeline nodes set/look up by it while `path` stays the real edit target. claude [P2]: session AI test_pipeline_node now arms the live run badge (onRunStarted), matching the route page. nit: pipelineAiHelpers.test uses afterEach(restoreAllMocks) instead of an unreachable inline mockRestore. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(pipeline): harden AI node mutations + close home label-filter / rename gaps Codex [P1] (AI mutations trust model paths) — fully scoped now: - editNode validates the open folder too (proposeNode already did), via a shared assertInFolder; an edit_pipeline_node for f/other/* no longer persists an unrelated script into the current folder's data_pipeline bundle. - both build_pipeline_node and edit_pipeline_node now require the `// pipeline` annotation (assertPipelineAnnotation) so a staged draft is definitionally a pipeline member, not a silently-non-member script. + tests. (proposeNode's folder + deployed-collision guards landed in the prior commit.) Codex [P2] home label filter — visiblePipelineFolders ignored labelFilter, so a label selection still showed every pipeline (and the empty-state fell through to render pipeline rows). Pipelines carry no labels, so a label filter hides them. Codex [P2] session rename — PipelineEditorView now wires onScriptRenamed (repoint selection + refetch), matching the route page; a persisted-script rename no longer leaves the canvas on the old path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(pipeline-ai): language-specific comment prefix for annotations Codex [P2]: the tool schema and prompt told the model to write `// pipeline` / `// on` / `// materialize` regardless of language, and pipeline-base.md grouped SQL with `#`. A `//` (or `#`) annotation line is invalid in a DuckDB/Postgres node — it passes the frontend parser (which strips `//`/`--`/`#`) but is a SQL syntax error at deploy/run. Make the guidance language-specific everywhere: `--` for SQL (duckdb/postgresql), `#` for python3/bash, `//` for bun/TS — the `//` in examples is the TS form to translate. Regenerated the prompt outputs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(pipeline-ai): re-scope Global prompt on folder switch + language-aware base prompt Codex [P2] x2: - The route page resets editor state on an in-app folder switch, but the Global chat's system message kept the old `/pipeline/<folder>` scope (the helper methods read the reactive folder, but the prompt string is only rebuilt on Global-mode reconfigure). Rebuild it on folder change so the next turn targets the new folder. - The pre-editor base Global prompt (seen before open_preview/get_instructions) still showed TS-only `// pipeline` / `// on`. Make it language-aware (`--` SQL, `#` Python/Bash, `//` TS) so the model can't draft invalid DuckDB/Postgres nodes before the pipeline tools are registered. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(pipeline-ai): authoritative new-node probe + SQL-correct eval checklist Codex [P2] x2: - build_pipeline_node's collision check relied on the resolved graph, which can be empty while the session preview races open_preview (a build could shadow a deployed node before the graph loads) and only covered pipeline runnables, not a non-pipeline script at the same path. Add an authoritative backend probe (ScriptService.getScriptByPath): any deployed script at the path → reject with "use edit_pipeline_node". + regression test (empty graph, deployed script). - The DuckLake eval judgeChecklist required the exact `// pipeline` annotation, which would penalize the now-correct `-- pipeline` SQL output (or reward invalid DuckDB syntax). Make both cases syntax-aware. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(pipeline-ai): rebuild Global prompt on session preview folder retarget Codex [P2]: open_preview(kind="pipeline", path="B") can retarget an existing pipeline preview from folder A to B without remounting. The retarget effect resets editor state and the helper methods read the new path, but the registration effect only depends on isActiveSession, so the Global system message stayed scoped to /pipeline/A. Mirror the route-page fix: rebuild the global system message on retarget (gated on isActiveSession — only the active session's helpers are registered; a hidden session reconfigures when it next becomes active). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(pipeline-ai): edit_pipeline_node preserves deployed script metadata Codex [P1]: editNode kept only the deployed script's language and staged a fresh makePipelineScript draft with empty hash/summary/description/tag/schema/settings. Deploying that edit from the pane (auto_parent) would update the script while wiping its metadata, and the route "Save all" path (no parent_hash) could hit the backend path-conflict branch on the occupied path. Base the draft on the existing draft's / deployed script object and replace ONLY content (+ inferred output assets), preserving hash and metadata. + regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2664 lines
95 KiB
Python
2664 lines
95 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Generate system prompts documentation from SDKs and OpenFlow schema.
|
|
|
|
This script:
|
|
1. Parses TypeScript SDK to extract function signatures
|
|
2. Parses Python SDK using Python's ast module
|
|
3. Parses OpenFlow YAML schema
|
|
4. Generates markdown files in sdks/ and schemas/
|
|
5. Assembles complete prompts and generates TypeScript exports in generated/
|
|
|
|
Usage:
|
|
python generate.py
|
|
python generate.py --plugin-dir /path/to/windmill-claude-plugin
|
|
python generate.py --context7-dir /path/to/windmill-cli-docs
|
|
"""
|
|
|
|
import argparse
|
|
import ast
|
|
import copy
|
|
import json
|
|
import re
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
from utils import (
|
|
# Path constants
|
|
SCRIPT_DIR,
|
|
TS_SDK_DIR,
|
|
PY_SDK_PATH,
|
|
OPENFLOW_SCHEMA_PATH,
|
|
BACKEND_OPENAPI_PATH,
|
|
OUTPUT_SDKS_DIR,
|
|
OUTPUT_GENERATED_DIR,
|
|
OUTPUT_CLI_DIR,
|
|
OUTPUT_SKILLS_DIR,
|
|
OUTPUT_SCHEMAS_DIR,
|
|
CLI_GUIDANCE_DIR,
|
|
CLI_MAIN,
|
|
CLI_COMMANDS_DIR,
|
|
# Language metadata
|
|
LANGUAGE_METADATA,
|
|
TS_SDK_LANGUAGES,
|
|
PY_SDK_LANGUAGES,
|
|
# Schema mappings
|
|
SCHEMA_MAPPINGS,
|
|
# String/file utilities
|
|
clean_jsdoc,
|
|
clean_params,
|
|
escape_for_ts,
|
|
read_markdown_file,
|
|
# Parsing utilities
|
|
extract_balanced,
|
|
extract_return_type,
|
|
parse_default_imports,
|
|
extract_options,
|
|
# Schema utilities
|
|
extract_cli_schema,
|
|
format_schema_for_markdown,
|
|
format_schema_as_json,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# TypeScript SDK Parsing
|
|
# =============================================================================
|
|
|
|
|
|
def extract_ts_functions(content: str) -> list[dict]:
|
|
"""Extract exported function signatures from TypeScript SDK."""
|
|
functions = []
|
|
seen_names = set()
|
|
|
|
# Pattern to find export function declarations (with or without JSDoc)
|
|
# Captures JSDoc if present, then the function declaration
|
|
pattern = re.compile(
|
|
r'(?:(/\*\*(?:[^*]|\*(?!/))*\*/)\s*)?' # Optional JSDoc comment
|
|
r'export\s+(async\s+)?function\s+(\w+)\s*' # export [async] function name
|
|
r'(<[^>]+>)?\s*', # optional generic
|
|
re.MULTILINE
|
|
)
|
|
|
|
for match in pattern.finditer(content):
|
|
jsdoc_raw, is_async, name, generic = match.groups()
|
|
|
|
if name in seen_names:
|
|
continue
|
|
|
|
# Find the opening parenthesis for parameters
|
|
pos = match.end()
|
|
while pos < len(content) and content[pos] in ' \t\n':
|
|
pos += 1
|
|
|
|
if pos >= len(content) or content[pos] != '(':
|
|
continue
|
|
|
|
# Extract balanced parameters
|
|
params, paren_end = extract_balanced(content, pos, '(', ')')
|
|
if paren_end == -1:
|
|
continue
|
|
|
|
# Extract return type (handles multi-line types like Promise<{...}>)
|
|
return_type, _ = extract_return_type(content, paren_end + 1)
|
|
|
|
if not return_type:
|
|
return_type = 'Promise<void>' if is_async else 'void'
|
|
|
|
docstring = clean_jsdoc(jsdoc_raw) if jsdoc_raw else ''
|
|
seen_names.add(name)
|
|
functions.append({
|
|
'name': name,
|
|
'generic': generic or '',
|
|
'params': clean_params(params),
|
|
'return_type': return_type,
|
|
'async': bool(is_async),
|
|
'docstring': docstring
|
|
})
|
|
|
|
return functions
|
|
|
|
|
|
def extract_ts_types(content: str) -> list[dict]:
|
|
"""Extract exported type definitions from TypeScript SDK."""
|
|
types = []
|
|
|
|
# Pattern for exported type aliases
|
|
type_pattern = re.compile(
|
|
r'export\s+type\s+(\w+)\s*=\s*([^;]+);',
|
|
re.MULTILINE
|
|
)
|
|
|
|
# Pattern for exported interfaces
|
|
interface_pattern = re.compile(
|
|
r'export\s+interface\s+(\w+)\s*\{([^}]+)\}',
|
|
re.MULTILINE | re.DOTALL
|
|
)
|
|
|
|
for match in type_pattern.finditer(content):
|
|
name, definition = match.groups()
|
|
types.append({
|
|
'name': name,
|
|
'kind': 'type',
|
|
'definition': definition.strip()
|
|
})
|
|
|
|
for match in interface_pattern.finditer(content):
|
|
name, body = match.groups()
|
|
types.append({
|
|
'name': name,
|
|
'kind': 'interface',
|
|
'definition': body.strip()
|
|
})
|
|
|
|
return types
|
|
|
|
|
|
# =============================================================================
|
|
# Python SDK Parsing
|
|
# =============================================================================
|
|
|
|
|
|
def extract_py_functions(content: str) -> list[dict]:
|
|
"""Extract function signatures from Python SDK using AST."""
|
|
functions = []
|
|
seen_names = set()
|
|
|
|
try:
|
|
tree = ast.parse(content)
|
|
except SyntaxError as e:
|
|
print(f"Warning: Could not parse Python SDK: {e}")
|
|
return functions
|
|
|
|
def process_function(node):
|
|
"""Process a function node and add to functions list if not duplicate."""
|
|
# Skip private functions
|
|
if node.name.startswith('_') and not node.name.startswith('__'):
|
|
return
|
|
# Skip duplicates
|
|
if node.name in seen_names:
|
|
return
|
|
|
|
# Get docstring
|
|
docstring = ast.get_docstring(node) or ''
|
|
|
|
# Build parameter list
|
|
params = []
|
|
args = node.args
|
|
|
|
# Handle regular args
|
|
num_defaults = len(args.defaults)
|
|
num_args = len(args.args)
|
|
|
|
for i, arg in enumerate(args.args):
|
|
if arg.arg == 'self':
|
|
continue
|
|
param_str = arg.arg
|
|
if arg.annotation:
|
|
param_str += f": {ast.unparse(arg.annotation)}"
|
|
# Check if has default
|
|
default_idx = i - (num_args - num_defaults)
|
|
if default_idx >= 0:
|
|
default = args.defaults[default_idx]
|
|
param_str += f" = {ast.unparse(default)}"
|
|
params.append(param_str)
|
|
|
|
# Handle *args
|
|
if args.vararg:
|
|
params.append(f"*{args.vararg.arg}")
|
|
|
|
# Handle keyword-only args
|
|
for i, arg in enumerate(args.kwonlyargs):
|
|
param_str = arg.arg
|
|
if arg.annotation:
|
|
param_str += f": {ast.unparse(arg.annotation)}"
|
|
if args.kw_defaults[i]:
|
|
param_str += f" = {ast.unparse(args.kw_defaults[i])}"
|
|
params.append(param_str)
|
|
|
|
# Handle **kwargs
|
|
if args.kwarg:
|
|
params.append(f"**{args.kwarg.arg}")
|
|
|
|
# Get return type
|
|
return_type = ''
|
|
if node.returns:
|
|
return_type = ast.unparse(node.returns)
|
|
|
|
seen_names.add(node.name)
|
|
functions.append({
|
|
'name': node.name,
|
|
'params': ', '.join(params),
|
|
'return_type': return_type,
|
|
'docstring': docstring,
|
|
'async': isinstance(node, ast.AsyncFunctionDef)
|
|
})
|
|
|
|
# Process top-level functions and class methods (but not nested functions)
|
|
for node in tree.body:
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
process_function(node)
|
|
elif isinstance(node, ast.ClassDef):
|
|
for item in node.body:
|
|
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
process_function(item)
|
|
|
|
return functions
|
|
|
|
|
|
def extract_py_classes(content: str) -> list[dict]:
|
|
"""Extract class definitions from Python SDK."""
|
|
classes = []
|
|
|
|
try:
|
|
tree = ast.parse(content)
|
|
except SyntaxError:
|
|
return classes
|
|
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.ClassDef):
|
|
methods = []
|
|
for item in node.body:
|
|
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
if not item.name.startswith('_') or item.name == '__init__':
|
|
docstring = ast.get_docstring(item) or ''
|
|
methods.append({
|
|
'name': item.name,
|
|
'docstring': docstring
|
|
})
|
|
|
|
classes.append({
|
|
'name': node.name,
|
|
'docstring': ast.get_docstring(node) or '',
|
|
'methods': methods
|
|
})
|
|
|
|
return classes
|
|
|
|
|
|
# =============================================================================
|
|
# CLI Command Parsing
|
|
# =============================================================================
|
|
|
|
|
|
# Reusable option pattern for CLI parsing. Matches both `.option(...)` and
|
|
# `.globalOption(...)` so subcommand-level global options surface in the docs.
|
|
OPTION_PATTERN = re.compile(
|
|
r'\.(?:option|globalOption)\(\s*"([^"]+)"\s*,\s*"([^"]+)"' # double-quoted
|
|
r'|'
|
|
r"\.(?:option|globalOption)\(\s*'([^']+)'\s*,\s*'([^']+)'", # single-quoted
|
|
re.MULTILINE | re.DOTALL
|
|
)
|
|
|
|
|
|
# A single JS string literal: double/single quoted or backtick. The other
|
|
# quote chars may appear inside (apostrophes inside a "..." string, etc.) and
|
|
# backslash escapes are consumed so a `\"` doesn't end the match early.
|
|
_STRING_LITERAL = (
|
|
r'"(?:[^"\\]|\\.)*"'
|
|
r"|'(?:[^'\\]|\\.)*'"
|
|
r'|`(?:[^`\\]|\\.)*`'
|
|
)
|
|
|
|
|
|
def _unquote_js_string(literal: str) -> str:
|
|
"""Drop the surrounding quotes of a JS string literal and unescape the
|
|
escapes that show up in command descriptions."""
|
|
body = literal[1:-1]
|
|
return (
|
|
body.replace('\\\\', '\x00')
|
|
.replace('\\n', '\n')
|
|
.replace('\\t', '\t')
|
|
.replace('\\"', '"')
|
|
.replace("\\'", "'")
|
|
.replace('\\`', '`')
|
|
.replace('\x00', '\\')
|
|
)
|
|
|
|
|
|
def extract_description(section: str) -> str | None:
|
|
"""Extract the text of the first chained `.description(...)` call.
|
|
|
|
Handles double/single-quoted and backtick strings (a quote of one kind may
|
|
appear inside a string delimited by another — e.g. an apostrophe inside a
|
|
"..." description), backslash escapes, and `"a" + "b"` concatenation across
|
|
lines. Returns None when `.description(` is absent or its argument is not a
|
|
string literal (e.g. a variable), matching the previous empty-description
|
|
behavior. Using `[^"\\']+` here instead would silently drop any description
|
|
containing an apostrophe.
|
|
"""
|
|
m = re.search(
|
|
r'\.description\(\s*((?:' + _STRING_LITERAL + r')(?:\s*\+\s*(?:' + _STRING_LITERAL + r'))*)',
|
|
section,
|
|
re.DOTALL,
|
|
)
|
|
if not m:
|
|
return None
|
|
parts = re.findall(_STRING_LITERAL, m.group(1), re.DOTALL)
|
|
return ''.join(_unquote_js_string(p) for p in parts).strip() or None
|
|
|
|
|
|
def parse_command_block(content: str, file_path: Path | None = None) -> dict:
|
|
"""
|
|
Parse a Cliffy Command() definition block and extract metadata.
|
|
Returns a dict with: description, options, subcommands, arguments, alias
|
|
|
|
If file_path is provided, imported subcommands will be resolved by parsing
|
|
the imported files.
|
|
"""
|
|
result = {
|
|
'description': '',
|
|
'options': [],
|
|
'subcommands': [],
|
|
'arguments': '',
|
|
'alias': ''
|
|
}
|
|
|
|
# Find the command block
|
|
command_match = re.search(
|
|
r'(?:const\s+command\s*=\s*)?new\s+Command\(\)([\s\S]*?)(?=export\s+default)',
|
|
content
|
|
)
|
|
if not command_match:
|
|
return result
|
|
|
|
block = command_match.group(1)
|
|
|
|
# Find where subcommands start
|
|
first_subcommand_pos = block.find('.command(')
|
|
if first_subcommand_pos == -1:
|
|
first_subcommand_pos = len(block)
|
|
top_section = block[:first_subcommand_pos]
|
|
|
|
# Extract main description
|
|
main_desc = extract_description(top_section)
|
|
if main_desc:
|
|
result['description'] = main_desc
|
|
|
|
# Extract alias
|
|
alias_match = re.search(r'\.alias\(\s*["\']([^"\']+)["\']\s*\)', top_section)
|
|
if alias_match:
|
|
result['alias'] = alias_match.group(1)
|
|
|
|
# Extract top-level options (before any .command() or .action())
|
|
top_section_until_action = re.split(r'\.action\(', top_section)[0]
|
|
result['options'] = extract_options(top_section_until_action, OPTION_PATTERN)
|
|
|
|
# Extract top-level arguments
|
|
args_match = re.search(r'\.arguments\(\s*["\']([^"\']+)["\']\s*\)', top_section)
|
|
if args_match:
|
|
result['arguments'] = args_match.group(1)
|
|
|
|
# Parse imports if we have a file path (for resolving imported subcommands)
|
|
imports = parse_default_imports(content) if file_path else {}
|
|
|
|
# Extract subcommands
|
|
subcommand_sections = re.split(r'(?=\.command\()', block)
|
|
|
|
for section in subcommand_sections:
|
|
# Second arg is either a quoted description, a bare identifier (imported
|
|
# command, e.g. `.command("app", app)`), or a more complex expression
|
|
# like `someWrapper(new Command()...)` — the `[^)]+` fallback covers
|
|
# the last case by matching up to the next `)`.
|
|
#
|
|
# Two subtleties:
|
|
# - The quoted-string alts are tried first so a description containing
|
|
# `(` like "(psql, DBeaver)" isn't truncated by the `[^)]+` fallback.
|
|
# - The trailing `,?` accommodates the prettier-style `\n )` close
|
|
# paren that follows a comma. Without it, the quoted alt would
|
|
# succeed but the outer `\s*\)` would fail, forcing a backtrack to
|
|
# `[^)]+` and producing a truncated description with a trailing `",`.
|
|
cmd_match = re.match(
|
|
r'\.command\(\s*["\']([^"\']+)["\']\s*'
|
|
r'(?:,\s*("(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'|[^)]+))?'
|
|
r'\s*,?\s*\)',
|
|
section,
|
|
)
|
|
if not cmd_match:
|
|
continue
|
|
|
|
# Explicit source marker for backwards-compatible CLI commands that
|
|
# should not be suggested in generated system prompts.
|
|
if '@deprecated' in section:
|
|
continue
|
|
|
|
# Hidden commands (Cliffy .hidden()) are internal — invoked by other
|
|
# Windmill components, not users — and must not surface in the
|
|
# generated agent system prompts or help.
|
|
if '.hidden()' in section:
|
|
continue
|
|
|
|
cmd_name = cmd_match.group(1)
|
|
second_arg = cmd_match.group(2).strip() if cmd_match.group(2) else ''
|
|
|
|
# Check if second arg is a string (description) or a variable (imported command)
|
|
is_string_desc = second_arg.startswith('"') or second_arg.startswith("'")
|
|
|
|
if is_string_desc:
|
|
cmd_desc = second_arg.strip('"\'')
|
|
elif second_arg and second_arg in imports and file_path:
|
|
# Imported command - resolve and parse the imported file
|
|
import_path = imports[second_arg]
|
|
if import_path.startswith('./') or import_path.startswith('../'):
|
|
imported_file = (file_path.parent / import_path).resolve()
|
|
if imported_file.exists():
|
|
try:
|
|
imported_content = imported_file.read_text()
|
|
imported_cmd = parse_command_block(imported_content, imported_file)
|
|
result['subcommands'].append({
|
|
'name': cmd_name,
|
|
'description': imported_cmd.get('description', ''),
|
|
'arguments': imported_cmd.get('arguments', ''),
|
|
'options': imported_cmd.get('options', [])
|
|
})
|
|
continue
|
|
except Exception as e:
|
|
print(f" Warning: Could not parse imported command {second_arg}: {e}")
|
|
cmd_desc = ''
|
|
else:
|
|
cmd_desc = ''
|
|
|
|
# Check for description in chained .description() call
|
|
chained_desc = extract_description(section)
|
|
if chained_desc:
|
|
cmd_desc = chained_desc
|
|
|
|
# Check for arguments
|
|
args_match = re.search(r'\.arguments\(\s*["\']([^"\']+)["\']\s*\)', section)
|
|
cmd_args = args_match.group(1) if args_match else ''
|
|
|
|
# Extract options specific to this subcommand (before .action())
|
|
section_until_action = re.split(r'\.action\(', section)[0]
|
|
cmd_options = extract_options(section_until_action, OPTION_PATTERN)
|
|
|
|
result['subcommands'].append({
|
|
'name': cmd_name,
|
|
'description': cmd_desc,
|
|
'arguments': cmd_args,
|
|
'options': cmd_options
|
|
})
|
|
|
|
return result
|
|
|
|
|
|
def find_command_file(cmd_name: str) -> Path | None:
|
|
"""Find the command file for a given command name."""
|
|
standard_path = CLI_COMMANDS_DIR / cmd_name / f"{cmd_name}.ts"
|
|
if standard_path.exists():
|
|
return standard_path
|
|
return None
|
|
|
|
|
|
def extract_cli_commands() -> dict:
|
|
"""
|
|
Extract CLI command metadata from the CLI source files.
|
|
Returns a dict with global_options and commands.
|
|
"""
|
|
result = {
|
|
'version': '',
|
|
'global_options': [],
|
|
'commands': []
|
|
}
|
|
|
|
if not CLI_MAIN.exists():
|
|
print(f"Warning: CLI main file not found at {CLI_MAIN}")
|
|
return result
|
|
|
|
main_content = CLI_MAIN.read_text()
|
|
|
|
# Extract version
|
|
version_match = re.search(r'export\s+const\s+VERSION\s*=\s*["\']([^"\']+)["\']', main_content)
|
|
if version_match:
|
|
result['version'] = version_match.group(1)
|
|
|
|
# Extract global options from main.ts
|
|
global_opt_pattern = re.compile(
|
|
r'\.globalOption\(\s*["\']([^"\']+)["\']\s*,\s*["\']([^"\']+)["\']\s*\)',
|
|
re.MULTILINE
|
|
)
|
|
for match in global_opt_pattern.finditer(main_content):
|
|
flag, desc = match.groups()
|
|
result['global_options'].append({'flag': flag, 'description': desc})
|
|
|
|
# Extract command registrations from main.ts
|
|
cmd_reg_pattern = re.compile(
|
|
r'\.command\(\s*["\']([^"\']+)["\']\s*,\s*(\w+)\s*\)',
|
|
re.MULTILINE
|
|
)
|
|
|
|
inline_cmd_pattern = re.compile(
|
|
r'\.command\(\s*["\']([^"\']+)["\']\s*,\s*["\']([^"\']+)["\']\s*\)',
|
|
re.MULTILINE
|
|
)
|
|
|
|
registered_commands = []
|
|
|
|
for match in cmd_reg_pattern.finditer(main_content):
|
|
cmd_name = match.group(1).split()[0]
|
|
registered_commands.append(cmd_name)
|
|
|
|
# Process each registered command
|
|
for cmd_name in registered_commands:
|
|
cmd_file = find_command_file(cmd_name)
|
|
if cmd_file:
|
|
try:
|
|
cmd_content = cmd_file.read_text()
|
|
cmd_data = parse_command_block(cmd_content, cmd_file)
|
|
cmd_data['name'] = cmd_name
|
|
result['commands'].append(cmd_data)
|
|
except Exception as e:
|
|
print(f"Warning: Could not parse command file for {cmd_name}: {e}")
|
|
|
|
# Handle special inline commands from main.ts
|
|
for match in inline_cmd_pattern.finditer(main_content):
|
|
cmd_name = match.group(1).split()[0]
|
|
cmd_desc = match.group(2)
|
|
if cmd_name not in [c['name'] for c in result['commands']]:
|
|
result['commands'].append({
|
|
'name': cmd_name,
|
|
'description': cmd_desc,
|
|
'options': [],
|
|
'subcommands': [],
|
|
'arguments': '',
|
|
'alias': ''
|
|
})
|
|
|
|
return result
|
|
|
|
|
|
# =============================================================================
|
|
# Markdown Generation
|
|
# =============================================================================
|
|
|
|
|
|
def generate_cli_commands_markdown(cli_data: dict) -> str:
|
|
"""Generate markdown documentation from extracted CLI command data."""
|
|
md = "# Windmill CLI Commands\n\n"
|
|
md += "The Windmill CLI (`wmill`) provides commands for managing scripts, flows, apps, and other resources.\n\n"
|
|
|
|
# Global options
|
|
if cli_data.get('global_options'):
|
|
md += "## Global Options\n\n"
|
|
for opt in cli_data['global_options']:
|
|
flag = opt['flag']
|
|
desc = opt['description']
|
|
md += f"- `{flag}` - {desc}\n"
|
|
md += "\n"
|
|
|
|
# Commands
|
|
if cli_data.get('commands'):
|
|
md += "## Commands\n\n"
|
|
|
|
for cmd in sorted(cli_data['commands'], key=lambda x: x['name']):
|
|
md += f"### {cmd['name']}\n\n"
|
|
|
|
if cmd.get('description'):
|
|
md += f"{cmd['description']}\n\n"
|
|
|
|
if cmd.get('alias'):
|
|
md += f"**Alias:** `{cmd['alias']}`\n\n"
|
|
|
|
if cmd.get('arguments'):
|
|
md += f"**Arguments:** `{cmd['arguments']}`\n\n"
|
|
|
|
# Top-level options for this command
|
|
if cmd.get('options'):
|
|
md += "**Options:**\n"
|
|
for opt in cmd['options']:
|
|
md += f"- `{opt['flag']}` - {opt['description']}\n"
|
|
md += "\n"
|
|
|
|
# Subcommands
|
|
if cmd.get('subcommands'):
|
|
md += "**Subcommands:**\n\n"
|
|
for sub in cmd['subcommands']:
|
|
sub_name = sub['name']
|
|
sub_args = f" {sub['arguments']}" if sub.get('arguments') else ""
|
|
sub_desc = sub.get('description', '')
|
|
|
|
md += f"- `{cmd['name']} {sub_name}{sub_args}`"
|
|
if sub_desc:
|
|
md += f" - {sub_desc}"
|
|
md += "\n"
|
|
|
|
# Subcommand options
|
|
if sub.get('options'):
|
|
for opt in sub['options']:
|
|
md += f" - `{opt['flag']}` - {opt['description']}\n"
|
|
|
|
md += "\n"
|
|
|
|
return md
|
|
|
|
|
|
def generate_ts_sdk_markdown(functions: list[dict], _types: list[dict]) -> str:
|
|
"""Generate compact documentation for TypeScript SDK."""
|
|
md = "# TypeScript SDK (windmill-client)\n\n"
|
|
md += "Import: import * as wmill from 'windmill-client'\n\n"
|
|
|
|
for i, func in enumerate(functions):
|
|
if func.get('docstring'):
|
|
# Format docstrings with JSDoc /** */ syntax
|
|
md += "/**\n"
|
|
docstring_lines = func['docstring'].split('\n')
|
|
for line in docstring_lines:
|
|
md += f" * {line}\n"
|
|
md += " */\n"
|
|
async_prefix = 'async ' if func['async'] else ''
|
|
md += f"{async_prefix}{func['name']}{func['generic']}({func['params']}): {func['return_type']}"
|
|
md += "\n"
|
|
if i < len(functions) - 1:
|
|
md += "\n"
|
|
|
|
return md
|
|
|
|
|
|
def generate_py_sdk_markdown(functions: list[dict], _classes: list[dict]) -> str:
|
|
"""Generate compact documentation for Python SDK."""
|
|
md = "# Python SDK (wmill)\n\n"
|
|
md += "Import: import wmill\n\n"
|
|
|
|
for func in functions:
|
|
# Skip private functions
|
|
if func['name'].startswith('_'):
|
|
continue
|
|
docstring = func.get('docstring')
|
|
if docstring:
|
|
# Format multi-line docstrings with # prefix on each line
|
|
docstring_lines = docstring.split('\n')
|
|
for line in docstring_lines:
|
|
md += f"# {line}\n"
|
|
async_prefix = 'async ' if func['async'] else ''
|
|
return_annotation = f" -> {func['return_type']}" if func['return_type'] else ''
|
|
md += f"{async_prefix}def {func['name']}({func['params']}){return_annotation}\n"
|
|
md += "\n"
|
|
|
|
return md
|
|
|
|
|
|
def generate_ts_exports(prompts: dict[str, str]) -> str:
|
|
"""Generate TypeScript file that exports all prompts."""
|
|
ts = "// Auto-generated by generate.py - DO NOT EDIT\n\n"
|
|
|
|
for name, content in prompts.items():
|
|
escaped = escape_for_ts(content)
|
|
ts += f"export const {name} = `{escaped}`;\n\n"
|
|
|
|
return ts
|
|
|
|
|
|
def generate_ts_declarations(prompts: dict[str, str]) -> str:
|
|
"""Generate the .d.ts for prompts.ts.
|
|
|
|
Each export is declared as a plain `string` rather than a string-literal
|
|
type so the declaration file does not embed (and drift against) the prompt
|
|
contents — those live only in prompts.ts.
|
|
"""
|
|
dts = "// Auto-generated by generate.py - DO NOT EDIT\n\n"
|
|
|
|
for name in prompts.keys():
|
|
dts += f"export declare const {name}: string;\n"
|
|
|
|
return dts
|
|
|
|
|
|
# =============================================================================
|
|
# Schema File Generation
|
|
# =============================================================================
|
|
|
|
|
|
def generate_schema_files(cli_schemas: dict[str, dict]) -> dict[str, str]:
|
|
"""
|
|
Generate standalone YAML schema files for triggers and schedules.
|
|
|
|
Returns a dict mapping schema keys (e.g., 'http_trigger') to YAML content.
|
|
"""
|
|
print("Generating standalone schema files...")
|
|
|
|
# Ensure schemas directory exists
|
|
OUTPUT_SCHEMAS_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
schema_yaml_content = {}
|
|
|
|
# Collect all schema types from SCHEMA_MAPPINGS
|
|
for skill_name, schema_types in SCHEMA_MAPPINGS.items():
|
|
for schema_name, file_suffix in schema_types:
|
|
if schema_name not in cli_schemas:
|
|
print(f" Warning: Schema '{schema_name}' not found, skipping")
|
|
continue
|
|
|
|
# Convert the schema to JSON Schema format
|
|
json_schema = format_schema_as_json(cli_schemas[schema_name])
|
|
if not json_schema:
|
|
print(f" Warning: Empty schema for '{schema_name}', skipping")
|
|
continue
|
|
|
|
# Convert to YAML
|
|
schema_yaml = yaml.dump(json_schema, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
|
|
|
# Write to file
|
|
schema_file = OUTPUT_SCHEMAS_DIR / f"{file_suffix}.schema.yaml"
|
|
schema_file.write_text(schema_yaml)
|
|
|
|
# Store for return
|
|
schema_yaml_content[file_suffix] = schema_yaml
|
|
|
|
print(f" Generated {len(schema_yaml_content)} schema files")
|
|
return schema_yaml_content
|
|
|
|
|
|
# =============================================================================
|
|
# Workspace Tool Zod Schema Generation
|
|
# =============================================================================
|
|
|
|
|
|
WORKSPACE_TOOL_ZOD_SCHEMAS = [
|
|
('NewSchedule', 'scheduleRequestSchema'),
|
|
('NewHttpTrigger', 'httpTriggerRequestSchema'),
|
|
('NewWebsocketTrigger', 'websocketTriggerRequestSchema'),
|
|
('NewKafkaTrigger', 'kafkaTriggerRequestSchema'),
|
|
('NewNatsTrigger', 'natsTriggerRequestSchema'),
|
|
('NewPostgresTrigger', 'postgresTriggerRequestSchema'),
|
|
('NewMqttTrigger', 'mqttTriggerRequestSchema'),
|
|
('NewSqsTrigger', 'sqsTriggerRequestSchema'),
|
|
('GcpTriggerData', 'gcpTriggerRequestSchema'),
|
|
('AzureTriggerData', 'azureTriggerRequestSchema'),
|
|
('CreateVariable', 'variableRequestSchema'),
|
|
('CreateResource', 'resourceRequestSchema'),
|
|
]
|
|
|
|
WORKSPACE_TOOL_TRIGGER_SCHEMAS = [
|
|
('http', 'httpTriggerRequestSchema'),
|
|
('websocket', 'websocketTriggerRequestSchema'),
|
|
('kafka', 'kafkaTriggerRequestSchema'),
|
|
('nats', 'natsTriggerRequestSchema'),
|
|
('postgres', 'postgresTriggerRequestSchema'),
|
|
('mqtt', 'mqttTriggerRequestSchema'),
|
|
('sqs', 'sqsTriggerRequestSchema'),
|
|
('gcp', 'gcpTriggerRequestSchema'),
|
|
('azure', 'azureTriggerRequestSchema'),
|
|
]
|
|
|
|
WORKSPACE_TOOL_ZOD_OUTPUT_PATH = (
|
|
SCRIPT_DIR.parent
|
|
/ 'frontend'
|
|
/ 'src'
|
|
/ 'lib'
|
|
/ 'components'
|
|
/ 'copilot'
|
|
/ 'chat'
|
|
/ 'workspaceToolsZod.gen.ts'
|
|
)
|
|
|
|
|
|
def _resolve_schema_refs(schema: dict, backend_schemas: dict, openflow_schemas: dict, seen: tuple[str, ...] = ()) -> dict:
|
|
"""Resolve OpenAPI refs so json-schema-to-zod emits concrete enums/objects."""
|
|
if isinstance(schema, list):
|
|
return [_resolve_schema_refs(item, backend_schemas, openflow_schemas, seen) for item in schema]
|
|
|
|
if not isinstance(schema, dict):
|
|
return schema
|
|
|
|
if '$ref' in schema:
|
|
ref = schema['$ref']
|
|
ref_name = ref.split('/')[-1]
|
|
if ref_name in seen:
|
|
return {'type': 'object'}
|
|
|
|
source = openflow_schemas if 'openflow.openapi.yaml' in ref or ref_name not in backend_schemas else backend_schemas
|
|
ref_schema = source.get(ref_name)
|
|
if not ref_schema:
|
|
return {'type': 'object'}
|
|
|
|
resolved = _resolve_schema_refs(copy.deepcopy(ref_schema), backend_schemas, openflow_schemas, (*seen, ref_name))
|
|
for key, value in schema.items():
|
|
if key != '$ref':
|
|
resolved[key] = _resolve_schema_refs(value, backend_schemas, openflow_schemas, seen)
|
|
return resolved
|
|
|
|
return {
|
|
key: _resolve_schema_refs(value, backend_schemas, openflow_schemas, seen)
|
|
for key, value in schema.items()
|
|
}
|
|
|
|
|
|
def _ts_string(value: str) -> str:
|
|
return json.dumps(value)
|
|
|
|
|
|
def _zod_literal(value) -> str:
|
|
return json.dumps(value)
|
|
|
|
|
|
def _apply_zod_metadata(expr: str, schema: dict) -> str:
|
|
if schema.get('description'):
|
|
expr += f".describe({_ts_string(schema['description'])})"
|
|
if schema.get('nullable'):
|
|
expr += ".nullable()"
|
|
if 'default' in schema:
|
|
expr += f".default({_zod_literal(schema['default'])})"
|
|
return expr
|
|
|
|
|
|
def _json_schema_to_zod(schema: dict, indent: int = 0) -> str:
|
|
schema = schema or {}
|
|
|
|
if 'oneOf' in schema:
|
|
raise ValueError('Unsupported oneOf in workspace tool Zod schema generation')
|
|
|
|
if 'allOf' in schema:
|
|
raise ValueError('Unsupported allOf in workspace tool Zod schema generation')
|
|
|
|
if 'anyOf' in schema:
|
|
expr = "z.union([{}])".format(
|
|
', '.join(_json_schema_to_zod(item, indent) for item in schema['anyOf'])
|
|
)
|
|
return _apply_zod_metadata(expr, schema)
|
|
|
|
if 'enum' in schema:
|
|
enum_values = ', '.join(_zod_literal(value) for value in schema['enum'])
|
|
expr = f"z.enum([{enum_values}])"
|
|
return _apply_zod_metadata(expr, schema)
|
|
|
|
schema_type = schema.get('type')
|
|
|
|
if schema_type == 'string':
|
|
expr = 'z.string()'
|
|
if schema.get('format') == 'date-time':
|
|
expr += '.datetime({ offset: true })'
|
|
elif schema_type == 'boolean':
|
|
expr = 'z.boolean()'
|
|
elif schema_type in ('number', 'integer'):
|
|
expr = 'z.number()'
|
|
if schema_type == 'integer':
|
|
expr += '.int()'
|
|
if 'minimum' in schema:
|
|
expr += f".gte({_zod_literal(schema['minimum'])})"
|
|
if 'maximum' in schema:
|
|
expr += f".lte({_zod_literal(schema['maximum'])})"
|
|
elif schema_type == 'array':
|
|
expr = f"z.array({_json_schema_to_zod(schema.get('items', {}), indent)})"
|
|
elif schema_type == 'object' or schema.get('properties') is not None or schema.get('additionalProperties') is not None:
|
|
properties = schema.get('properties') or {}
|
|
if not properties and schema.get('additionalProperties'):
|
|
expr = 'z.record(z.string(), z.any())'
|
|
else:
|
|
required = set(schema.get('required') or [])
|
|
prop_lines = []
|
|
child_indent = '\t' * (indent + 1)
|
|
closing_indent = '\t' * indent
|
|
for key, value in properties.items():
|
|
prop_expr = _json_schema_to_zod(value, indent + 1)
|
|
if key not in required:
|
|
prop_expr += '.optional()'
|
|
prop_lines.append(f"{child_indent}{_ts_string(key)}: {prop_expr}")
|
|
if prop_lines:
|
|
expr = "z.object({\n" + ",\n".join(prop_lines) + f"\n{closing_indent}}})"
|
|
else:
|
|
expr = 'z.object({})'
|
|
else:
|
|
expr = 'z.any()'
|
|
|
|
return _apply_zod_metadata(expr, schema)
|
|
|
|
|
|
def generate_workspace_tool_zod_schemas(backend_schemas: dict, openflow_schemas: dict) -> None:
|
|
"""Generate Zod schemas used by frontend AI chat workspace mutation tools."""
|
|
print("Generating workspace tool Zod schemas...")
|
|
|
|
missing = [schema_name for schema_name, _ in WORKSPACE_TOOL_ZOD_SCHEMAS if schema_name not in backend_schemas]
|
|
if missing:
|
|
print(f" Warning: Missing schemas for workspace tool Zod generation: {', '.join(missing)}")
|
|
return
|
|
|
|
trigger_path_description = (
|
|
backend_schemas.get('NewHttpTrigger', {})
|
|
.get('properties', {})
|
|
.get('path', {})
|
|
.get('description')
|
|
or "The new trigger's Windmill path"
|
|
)
|
|
|
|
lines = [
|
|
"// Auto-generated by generate.py - DO NOT EDIT",
|
|
"",
|
|
"import { z } from 'zod'",
|
|
"",
|
|
]
|
|
|
|
for schema_name, export_name in WORKSPACE_TOOL_ZOD_SCHEMAS:
|
|
schema = _resolve_schema_refs(
|
|
copy.deepcopy(backend_schemas[schema_name]),
|
|
backend_schemas,
|
|
openflow_schemas,
|
|
)
|
|
lines.append(f"export const {export_name} = {_json_schema_to_zod(schema)}")
|
|
lines.append("")
|
|
|
|
lines.extend([
|
|
"export const triggerRequestSchemas = {",
|
|
*[
|
|
f"\t{kind}: {schema_name},"
|
|
for kind, schema_name in WORKSPACE_TOOL_TRIGGER_SCHEMAS
|
|
],
|
|
"} as const",
|
|
"",
|
|
f"const triggerPathSchema = z.string().min(1).describe({_ts_string(trigger_path_description)})",
|
|
"",
|
|
"export const createTriggerToolSchema = z.object({",
|
|
"\tkind: z.enum([",
|
|
*[
|
|
f"\t\t{_ts_string(kind)},"
|
|
for kind, _ in WORKSPACE_TOOL_TRIGGER_SCHEMAS
|
|
],
|
|
"\t]),",
|
|
"\tpath: triggerPathSchema,",
|
|
"\tconfig: z.union([",
|
|
])
|
|
for kind, schema_name in WORKSPACE_TOOL_TRIGGER_SCHEMAS:
|
|
lines.append(f"\t\t{schema_name}.omit({{ path: true, script_path: true, is_flow: true }}),")
|
|
lines.extend([
|
|
"\t])",
|
|
"})",
|
|
])
|
|
lines.append("")
|
|
|
|
WORKSPACE_TOOL_ZOD_OUTPUT_PATH.write_text("\n".join(lines))
|
|
print(" Generated workspaceToolsZod.gen.ts")
|
|
|
|
|
|
# =============================================================================
|
|
# Datatable SDK Extraction
|
|
# =============================================================================
|
|
|
|
|
|
TS_SQL_UTILS_PATH = TS_SDK_DIR / "sqlUtils.ts"
|
|
|
|
|
|
def extract_datatable_ts_sdk() -> str:
|
|
"""Extract datatable-specific type definitions from TypeScript SDK (sqlUtils.ts).
|
|
|
|
Reads the source file and extracts the public API surface:
|
|
- SqlStatement<T> type (fetch, fetchOne, fetchOneScalar, execute methods)
|
|
- DatatableSqlTemplateFunction interface (template tag + query method)
|
|
- datatable() function signature
|
|
"""
|
|
if not TS_SQL_UTILS_PATH.exists():
|
|
print(f" Warning: sqlUtils.ts not found at {TS_SQL_UTILS_PATH}")
|
|
return ''
|
|
|
|
content = TS_SQL_UTILS_PATH.read_text()
|
|
|
|
md = "## TypeScript Datatable API (windmill-client)\n\n"
|
|
md += "Import: `import * as wmill from 'windmill-client'`\n\n"
|
|
|
|
# Extract exported type/interface/function definitions from sqlUtils.ts
|
|
# We use extract_balanced to handle nested braces correctly
|
|
|
|
# 1. Extract SqlStatement<T> type
|
|
match = re.search(r'(\/\*\*(?:[^*]|\*(?!\/))*\*\/\s*)?export\s+type\s+SqlStatement<T>\s*=\s*', content)
|
|
if match:
|
|
jsdoc_raw = match.group(1)
|
|
brace_start = content.index('{', match.end() - 1)
|
|
body, end = extract_balanced(content, brace_start, '{', '}')
|
|
if end != -1:
|
|
if jsdoc_raw:
|
|
md += clean_jsdoc(jsdoc_raw) + "\n"
|
|
md += "```typescript\n"
|
|
md += f"type SqlStatement<T> = {{\n{_indent_body(body)}\n}};\n"
|
|
md += "```\n\n"
|
|
|
|
# 2. Extract DatatableSqlTemplateFunction interface
|
|
match = re.search(
|
|
r'(\/\*\*(?:[^*]|\*(?!\/))*\*\/\s*)?export\s+interface\s+DatatableSqlTemplateFunction\s+extends\s+SqlTemplateFunction\s*',
|
|
content
|
|
)
|
|
if match:
|
|
brace_start = content.index('{', match.end() - 1)
|
|
body, end = extract_balanced(content, brace_start, '{', '}')
|
|
if end != -1:
|
|
md += "```typescript\n"
|
|
md += "// Template tag function: sql`SELECT * FROM table WHERE id = ${id}`.fetch()\n"
|
|
md += f"interface DatatableSqlTemplateFunction {{\n"
|
|
md += f" // Tagged template usage:\n"
|
|
md += f" <T = any>(strings: TemplateStringsArray, ...values: any[]): SqlStatement<T>;\n"
|
|
md += f"{_indent_body(body)}\n"
|
|
md += "};\n"
|
|
md += "```\n\n"
|
|
|
|
# 3. Extract datatable() function
|
|
match = re.search(
|
|
r'(\/\*\*(?:[^*]|\*(?!\/))*\*\/\s*)?export\s+function\s+datatable\s*\(([^)]*)\)\s*:\s*(\S+)',
|
|
content
|
|
)
|
|
if match:
|
|
jsdoc_raw, params, return_type = match.groups()
|
|
if jsdoc_raw:
|
|
md += clean_jsdoc(jsdoc_raw) + "\n"
|
|
md += "```typescript\n"
|
|
md += f"function datatable({params.strip()}): {return_type}\n"
|
|
md += "```\n"
|
|
|
|
return md
|
|
|
|
|
|
def extract_datatable_py_sdk(py_content: str) -> str:
|
|
"""Extract datatable-specific class/function definitions from Python SDK.
|
|
|
|
Uses Python AST to extract:
|
|
- datatable() function
|
|
- DataTableClient class with query() method
|
|
- SqlQuery class with fetch(), fetch_one(), fetch_one_scalar(), execute() methods
|
|
"""
|
|
if not py_content:
|
|
return ''
|
|
|
|
try:
|
|
tree = ast.parse(py_content)
|
|
except SyntaxError as e:
|
|
print(f" Warning: Could not parse Python SDK for datatable extraction: {e}")
|
|
return ''
|
|
|
|
md = "## Python Datatable API (wmill)\n\n"
|
|
md += "Import: `import wmill`\n\n"
|
|
|
|
# Target classes and the top-level datatable function
|
|
target_classes = {'DataTableClient', 'SqlQuery'}
|
|
|
|
# 1. Extract datatable() top-level function
|
|
for node in tree.body:
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == 'datatable':
|
|
docstring = ast.get_docstring(node) or ''
|
|
params = _format_py_params(node)
|
|
return_ann = f" -> {ast.unparse(node.returns)}" if node.returns else ''
|
|
if docstring:
|
|
for line in docstring.split('\n'):
|
|
md += f"# {line}\n"
|
|
md += f"def datatable({params}){return_ann}\n\n"
|
|
break
|
|
|
|
# 2. Extract target classes with their public methods
|
|
for node in tree.body:
|
|
if isinstance(node, ast.ClassDef) and node.name in target_classes:
|
|
class_doc = ast.get_docstring(node) or ''
|
|
if class_doc:
|
|
for line in class_doc.split('\n'):
|
|
md += f"# {line}\n"
|
|
md += f"class {node.name}:\n"
|
|
|
|
for item in node.body:
|
|
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
if item.name.startswith('_') and item.name != '__init__':
|
|
continue
|
|
docstring = ast.get_docstring(item) or ''
|
|
params = _format_py_params(item, skip_self=True)
|
|
return_ann = f" -> {ast.unparse(item.returns)}" if item.returns else ''
|
|
async_prefix = 'async ' if isinstance(item, ast.AsyncFunctionDef) else ''
|
|
if docstring:
|
|
for line in docstring.split('\n'):
|
|
md += f" # {line}\n"
|
|
md += f" {async_prefix}def {item.name}({params}){return_ann}\n\n"
|
|
|
|
md += "\n"
|
|
|
|
return md
|
|
|
|
|
|
def _format_py_params(node: ast.FunctionDef, skip_self: bool = False) -> str:
|
|
"""Format function parameters from AST node."""
|
|
params = []
|
|
args = node.args
|
|
num_defaults = len(args.defaults)
|
|
num_args = len(args.args)
|
|
|
|
for i, arg in enumerate(args.args):
|
|
if skip_self and arg.arg == 'self':
|
|
continue
|
|
param_str = arg.arg
|
|
if arg.annotation:
|
|
param_str += f": {ast.unparse(arg.annotation)}"
|
|
default_idx = i - (num_args - num_defaults)
|
|
if default_idx >= 0:
|
|
default = args.defaults[default_idx]
|
|
param_str += f" = {ast.unparse(default)}"
|
|
params.append(param_str)
|
|
|
|
if args.vararg:
|
|
vararg_str = f"*{args.vararg.arg}"
|
|
if args.vararg.annotation:
|
|
vararg_str += f": {ast.unparse(args.vararg.annotation)}"
|
|
params.append(vararg_str)
|
|
|
|
for i, arg in enumerate(args.kwonlyargs):
|
|
param_str = arg.arg
|
|
if arg.annotation:
|
|
param_str += f": {ast.unparse(arg.annotation)}"
|
|
if args.kw_defaults[i]:
|
|
param_str += f" = {ast.unparse(args.kw_defaults[i])}"
|
|
params.append(param_str)
|
|
|
|
if args.kwarg:
|
|
kwarg_str = f"**{args.kwarg.arg}"
|
|
if args.kwarg.annotation:
|
|
kwarg_str += f": {ast.unparse(args.kwarg.annotation)}"
|
|
params.append(kwarg_str)
|
|
|
|
return ', '.join(params)
|
|
|
|
|
|
def _indent_body(body: str) -> str:
|
|
"""Clean and re-indent a type body for readable output."""
|
|
lines = body.strip().split('\n')
|
|
result = []
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if stripped:
|
|
# Keep JSDoc comments and method signatures with consistent indentation
|
|
if not stripped.startswith('//') and not stripped.startswith('/*') and not stripped.startswith('*'):
|
|
result.append(f" {stripped}")
|
|
else:
|
|
result.append(f" {stripped}")
|
|
else:
|
|
result.append('')
|
|
return '\n'.join(result)
|
|
|
|
|
|
# =============================================================================
|
|
# Workflow-as-Code SDK Extraction
|
|
# =============================================================================
|
|
|
|
|
|
WAC_TS_FUNCTIONS = [
|
|
'getResumeUrls',
|
|
'task',
|
|
'taskScript',
|
|
'taskFlow',
|
|
'workflow',
|
|
'step',
|
|
'sleep',
|
|
'waitForApproval',
|
|
'parallel',
|
|
]
|
|
|
|
WAC_PY_FUNCTIONS = [
|
|
'get_resume_urls',
|
|
'task',
|
|
'task_script',
|
|
'task_flow',
|
|
'workflow',
|
|
'step',
|
|
'sleep',
|
|
'wait_for_approval',
|
|
'parallel',
|
|
]
|
|
|
|
|
|
def _extract_ts_angle_params(content: str, start_pos: int) -> tuple[str, int]:
|
|
"""Extract TypeScript generic parameters, ignoring arrow `=>` tokens."""
|
|
if start_pos >= len(content) or content[start_pos] != '<':
|
|
return '', start_pos
|
|
|
|
depth = 0
|
|
i = start_pos
|
|
quote: str | None = None
|
|
while i < len(content):
|
|
char = content[i]
|
|
prev = content[i - 1] if i > 0 else ''
|
|
|
|
if quote:
|
|
if char == '\\':
|
|
i += 2
|
|
continue
|
|
if char == quote:
|
|
quote = None
|
|
i += 1
|
|
continue
|
|
|
|
if char in ('"', "'", '`'):
|
|
quote = char
|
|
elif char == '<':
|
|
depth += 1
|
|
elif char == '>' and prev != '=':
|
|
depth -= 1
|
|
if depth == 0:
|
|
return content[start_pos:i + 1], i + 1
|
|
i += 1
|
|
|
|
return '', -1
|
|
|
|
|
|
def _render_ts_jsdoc(jsdoc_raw: str | None) -> str:
|
|
if not jsdoc_raw:
|
|
return ''
|
|
|
|
docstring = clean_jsdoc(jsdoc_raw)
|
|
if not docstring:
|
|
return ''
|
|
|
|
lines = ["/**"]
|
|
for line in docstring.split('\n'):
|
|
lines.append(f" * {line}" if line else " *")
|
|
lines.append(" */")
|
|
return '\n'.join(lines)
|
|
|
|
|
|
def _extract_ts_interface(content: str, name: str) -> str:
|
|
pattern = re.compile(
|
|
r'(?:(/\*\*(?:[^*]|\*(?!/))*\*/)\s*)?'
|
|
rf'export\s+interface\s+{re.escape(name)}\s*',
|
|
re.MULTILINE
|
|
)
|
|
match = pattern.search(content)
|
|
if not match:
|
|
return ''
|
|
|
|
try:
|
|
brace_start = content.index('{', match.end() - 1)
|
|
except ValueError:
|
|
return ''
|
|
|
|
body, end = extract_balanced(content, brace_start, '{', '}')
|
|
if end == -1:
|
|
return ''
|
|
|
|
parts = []
|
|
jsdoc = _render_ts_jsdoc(match.group(1))
|
|
if jsdoc:
|
|
parts.append(jsdoc)
|
|
parts.append(f"export interface {name} {{\n{_indent_body(body)}\n}}")
|
|
return '\n'.join(parts)
|
|
|
|
|
|
def _extract_ts_exported_function(content: str, name: str) -> str:
|
|
pattern = re.compile(
|
|
r'(?:(/\*\*(?:[^*]|\*(?!/))*\*/)\s*)?'
|
|
rf'export\s+(async\s+)?function\s+{re.escape(name)}\s*',
|
|
re.MULTILINE
|
|
)
|
|
match = pattern.search(content)
|
|
if not match:
|
|
return ''
|
|
|
|
jsdoc_raw, is_async = match.groups()
|
|
pos = match.end()
|
|
while pos < len(content) and content[pos] in ' \t\n':
|
|
pos += 1
|
|
|
|
generic = ''
|
|
if pos < len(content) and content[pos] == '<':
|
|
generic, pos = _extract_ts_angle_params(content, pos)
|
|
if pos == -1:
|
|
return ''
|
|
while pos < len(content) and content[pos] in ' \t\n':
|
|
pos += 1
|
|
|
|
if pos >= len(content) or content[pos] != '(':
|
|
return ''
|
|
|
|
params, paren_end = extract_balanced(content, pos, '(', ')')
|
|
if paren_end == -1:
|
|
return ''
|
|
|
|
return_type, _ = extract_return_type(content, paren_end + 1)
|
|
async_prefix = 'async ' if is_async else ''
|
|
signature = f"export {async_prefix}function {name}{generic}({clean_params(params)})"
|
|
if return_type:
|
|
signature += f": {clean_params(return_type)}"
|
|
|
|
parts = []
|
|
jsdoc = _render_ts_jsdoc(jsdoc_raw)
|
|
if jsdoc:
|
|
parts.append(jsdoc)
|
|
parts.append(signature)
|
|
return '\n'.join(parts)
|
|
|
|
|
|
def extract_wac_ts_sdk(ts_content: str) -> str:
|
|
"""Extract Workflow-as-Code API signatures from the TypeScript SDK."""
|
|
if not ts_content:
|
|
return ''
|
|
|
|
declarations = []
|
|
task_options = _extract_ts_interface(ts_content, 'TaskOptions')
|
|
if task_options:
|
|
declarations.append(task_options)
|
|
|
|
for function_name in WAC_TS_FUNCTIONS:
|
|
signature = _extract_ts_exported_function(ts_content, function_name)
|
|
if signature:
|
|
declarations.append(signature)
|
|
else:
|
|
print(f" Warning: TypeScript WAC function '{function_name}' not found")
|
|
|
|
if not declarations:
|
|
return ''
|
|
|
|
md = "## TypeScript Workflow-as-Code API (windmill-client)\n\n"
|
|
md += 'Import: `import { workflow, task, taskScript, taskFlow, step, sleep, waitForApproval, getResumeUrls, parallel } from "windmill-client"`\n\n'
|
|
md += "```typescript\n"
|
|
md += "\n\n".join(declarations)
|
|
md += "\n```\n"
|
|
return md
|
|
|
|
|
|
def _format_py_params_exact(node, skip_self: bool = False) -> str:
|
|
"""Format Python parameters from AST, preserving bare * for keyword-only args."""
|
|
params = []
|
|
args = node.args
|
|
|
|
positional = list(args.posonlyargs) + list(args.args)
|
|
num_defaults = len(args.defaults)
|
|
num_positional = len(positional)
|
|
|
|
for i, arg in enumerate(positional):
|
|
if skip_self and arg.arg == 'self':
|
|
continue
|
|
param_str = arg.arg
|
|
if arg.annotation:
|
|
param_str += f": {ast.unparse(arg.annotation)}"
|
|
default_idx = i - (num_positional - num_defaults)
|
|
if default_idx >= 0:
|
|
param_str += f" = {ast.unparse(args.defaults[default_idx])}"
|
|
params.append(param_str)
|
|
|
|
if args.vararg:
|
|
vararg_str = f"*{args.vararg.arg}"
|
|
if args.vararg.annotation:
|
|
vararg_str += f": {ast.unparse(args.vararg.annotation)}"
|
|
params.append(vararg_str)
|
|
elif args.kwonlyargs:
|
|
params.append('*')
|
|
|
|
for i, arg in enumerate(args.kwonlyargs):
|
|
param_str = arg.arg
|
|
if arg.annotation:
|
|
param_str += f": {ast.unparse(arg.annotation)}"
|
|
if args.kw_defaults[i] is not None:
|
|
param_str += f" = {ast.unparse(args.kw_defaults[i])}"
|
|
params.append(param_str)
|
|
|
|
if args.kwarg:
|
|
kwarg_str = f"**{args.kwarg.arg}"
|
|
if args.kwarg.annotation:
|
|
kwarg_str += f": {ast.unparse(args.kwarg.annotation)}"
|
|
params.append(kwarg_str)
|
|
|
|
return ', '.join(params)
|
|
|
|
|
|
def _render_py_docstring(docstring: str, indent: str = '') -> str:
|
|
if not docstring:
|
|
return ''
|
|
return '\n'.join(f"{indent}# {line}" if line else f"{indent}#" for line in docstring.split('\n'))
|
|
|
|
|
|
def _extract_py_function_signature(tree: ast.Module, name: str) -> str:
|
|
for node in tree.body:
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name:
|
|
docstring = ast.get_docstring(node) or ''
|
|
params = _format_py_params_exact(node)
|
|
return_ann = f" -> {ast.unparse(node.returns)}" if node.returns else ''
|
|
async_prefix = 'async ' if isinstance(node, ast.AsyncFunctionDef) else ''
|
|
parts = []
|
|
rendered_docstring = _render_py_docstring(docstring)
|
|
if rendered_docstring:
|
|
parts.append(rendered_docstring)
|
|
parts.append(f"{async_prefix}def {node.name}({params}){return_ann}")
|
|
return '\n'.join(parts)
|
|
return ''
|
|
|
|
|
|
def _extract_py_class_signature(tree: ast.Module, name: str) -> str:
|
|
for node in tree.body:
|
|
if isinstance(node, ast.ClassDef) and node.name == name:
|
|
parts = []
|
|
docstring = _render_py_docstring(ast.get_docstring(node) or '')
|
|
if docstring:
|
|
parts.append(docstring)
|
|
bases = f"({', '.join(ast.unparse(base) for base in node.bases)})" if node.bases else ''
|
|
parts.append(f"class {node.name}{bases}:")
|
|
for item in node.body:
|
|
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and item.name == '__init__':
|
|
init_docstring = _render_py_docstring(ast.get_docstring(item) or '', indent=' ')
|
|
if init_docstring:
|
|
parts.append(init_docstring)
|
|
params = _format_py_params_exact(item)
|
|
parts.append(f" def __init__({params})")
|
|
break
|
|
return '\n'.join(parts)
|
|
return ''
|
|
|
|
|
|
def extract_wac_py_sdk(py_content: str) -> str:
|
|
"""Extract Workflow-as-Code API signatures from the Python SDK."""
|
|
if not py_content:
|
|
return ''
|
|
|
|
try:
|
|
tree = ast.parse(py_content)
|
|
except SyntaxError as e:
|
|
print(f" Warning: Could not parse Python SDK for WAC extraction: {e}")
|
|
return ''
|
|
|
|
declarations = []
|
|
task_error = _extract_py_class_signature(tree, 'TaskError')
|
|
if task_error:
|
|
declarations.append(task_error)
|
|
|
|
for function_name in WAC_PY_FUNCTIONS:
|
|
signature = _extract_py_function_signature(tree, function_name)
|
|
if signature:
|
|
declarations.append(signature)
|
|
else:
|
|
print(f" Warning: Python WAC function '{function_name}' not found")
|
|
|
|
if not declarations:
|
|
return ''
|
|
|
|
md = "## Python Workflow-as-Code API (wmill)\n\n"
|
|
md += "Import: `from wmill import workflow, task, task_script, task_flow, step, sleep, wait_for_approval, get_resume_urls, parallel, TaskError`\n\n"
|
|
md += "```python\n"
|
|
md += "\n\n".join(declarations)
|
|
md += "\n```\n"
|
|
return md
|
|
|
|
|
|
# =============================================================================
|
|
# Skill Generation
|
|
# =============================================================================
|
|
|
|
|
|
def generate_skill_content(
|
|
skill_name: str,
|
|
description: str,
|
|
intro: str,
|
|
content: str,
|
|
sdk_content: str = ''
|
|
) -> str:
|
|
"""Generate a skill file with YAML frontmatter."""
|
|
parts = [
|
|
"---",
|
|
f"name: {skill_name}",
|
|
f"description: {description}",
|
|
"---",
|
|
"",
|
|
]
|
|
if intro:
|
|
parts.extend([intro, ""])
|
|
parts.append(content)
|
|
if sdk_content:
|
|
parts.extend(["", sdk_content])
|
|
return '\n'.join(parts)
|
|
|
|
|
|
# Skill definitions for config-driven generation
|
|
SKILL_DEFINITIONS = [
|
|
{
|
|
'name': 'write-flow',
|
|
'description': 'MUST use when creating flows.',
|
|
'content_key': 'flow',
|
|
},
|
|
{
|
|
'name': 'raw-app',
|
|
'description': 'MUST use when creating raw apps.',
|
|
'content_key': 'raw_app',
|
|
},
|
|
{
|
|
'name': 'triggers',
|
|
'description': 'MUST use when configuring triggers.',
|
|
'content_key': 'triggers',
|
|
'schema_types': [
|
|
('HttpTrigger', 'http_trigger'),
|
|
('WebsocketTrigger', 'websocket_trigger'),
|
|
('KafkaTrigger', 'kafka_trigger'),
|
|
('NatsTrigger', 'nats_trigger'),
|
|
('PostgresTrigger', 'postgres_trigger'),
|
|
('MqttTrigger', 'mqtt_trigger'),
|
|
('SqsTrigger', 'sqs_trigger'),
|
|
('GcpTrigger', 'gcp_trigger'),
|
|
('AzureTrigger', 'azure_trigger'),
|
|
('EmailTrigger', 'email_trigger'),
|
|
],
|
|
},
|
|
{
|
|
'name': 'schedules',
|
|
'description': 'MUST use when configuring schedules.',
|
|
'content_key': 'schedules',
|
|
'schema_types': [('Schedule', 'schedule')],
|
|
},
|
|
{
|
|
'name': 'resources',
|
|
'description': 'MUST use when managing resources.',
|
|
'content_key': 'resources',
|
|
},
|
|
{
|
|
'name': 'write-workflow-as-code',
|
|
'description': 'MUST use when writing or modifying Windmill Workflow-as-Code scripts using workflow, task, step, sleep, approvals, taskScript, taskFlow, task_script, or task_flow.',
|
|
'content_key': 'workflow_as_code',
|
|
'intro_key': 'wac_cli',
|
|
'sdk_content_key': 'wac',
|
|
},
|
|
{
|
|
'name': 'cli-commands',
|
|
'description': 'MUST use when using the CLI, including debugging job failures and inspecting run history via `wmill job`.',
|
|
'content_key': 'cli_commands',
|
|
},
|
|
{
|
|
'name': 'preview',
|
|
'description': 'MUST use when opening the Windmill dev page / visual preview of a flow, script, or app. Triggers on words like preview, open, navigate to, visualize, see the flow/app/script, and after writing a flow/script/app for visual verification.',
|
|
'content_key': 'preview',
|
|
},
|
|
]
|
|
|
|
|
|
def generate_skills(
|
|
languages: dict[str, str],
|
|
ts_sdk_md: str,
|
|
py_sdk_md: str,
|
|
wac_ts_md: str,
|
|
wac_py_md: str,
|
|
flow_cli: str,
|
|
flow_base: str,
|
|
openflow_content: str,
|
|
cli_commands: str,
|
|
cli_schemas: dict[str, dict] | None = None
|
|
):
|
|
"""Generate individual skill files for Claude Code."""
|
|
print("Generating skill files...")
|
|
|
|
cli_schemas = cli_schemas or {}
|
|
|
|
# Ensure skills directory exists
|
|
OUTPUT_SKILLS_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Read base files for additional skills.
|
|
# Note: raw-app.md is the chat-relevant authoring guide. The CLI workflow
|
|
# (wmill app new wizard, on-disk layout, sql_to_apply/, CLI commands) lives
|
|
# in raw-app-cli.md. Concatenated here for the skill so CLI users see CLI
|
|
# guidance first, then the platform shape.
|
|
base_dir = SCRIPT_DIR / "base"
|
|
raw_app_cli_md = read_markdown_file(base_dir / "raw-app-cli.md")
|
|
raw_app_authoring_md = read_markdown_file(base_dir / "raw-app.md")
|
|
base_content = {
|
|
'flow': f"{flow_cli}\n\n{flow_base}\n\n{openflow_content}",
|
|
'raw_app': f"{raw_app_cli_md}\n\n{raw_app_authoring_md}",
|
|
'triggers': read_markdown_file(base_dir / "triggers.md"),
|
|
'schedules': read_markdown_file(base_dir / "schedules.md"),
|
|
'resources': read_markdown_file(base_dir / "resources.md"),
|
|
'workflow_as_code': read_markdown_file(base_dir / "workflow-as-code.md"),
|
|
'cli_commands': cli_commands,
|
|
'preview': read_markdown_file(base_dir / "preview.md"),
|
|
}
|
|
|
|
# CLI intro for script skills
|
|
script_cli_intro = """## CLI Commands
|
|
|
|
Place scripts in a folder.
|
|
|
|
After writing, tell the user which command fits what they want to do:
|
|
|
|
- `wmill script preview <script_path>` — **default when iterating on a local script.** Runs the local file without deploying.
|
|
- `wmill script run <path>` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
|
|
- `wmill generate-metadata` — regenerate the local `.script.yaml` (input schema) and `.lock` (resolved dependencies) for scripts you changed, and refresh their content hashes in `wmill-lock.yaml`. Local files only — **not** a deploy. See "Keep metadata in sync" below.
|
|
- `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".
|
|
|
|
### Preview vs run — choose by intent, not habit
|
|
|
|
If the user says "run the script", "try it", "test it", "does it work" while there are **local edits to the script file**, use `script preview`. Do NOT push the script to then `script run` it — pushing is a deploy, and deploying just to test overwrites the workspace version with untested changes.
|
|
|
|
Only use `script run` when:
|
|
- The user explicitly says "run the deployed version" / "run what's on the server".
|
|
- There is no local script being edited (you're just invoking an existing script).
|
|
|
|
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.
|
|
|
|
### Keep metadata in sync after editing
|
|
|
|
`wmill-lock.yaml` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing `main`'s arguments** — invalidates that hash and leaves the `.lock`, the `.script.yaml` input schema, and the hash row out of date. Run `wmill generate-metadata` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by `.script.yaml`), and `wmill-lock.yaml` all match the code. Leaving them stale produces spurious diffs in git-sync and CI.
|
|
|
|
This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's `AGENTS.md` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated `.lock` / `.script.lock` files and tell the user which dependency versions changed (e.g. `requests 2.31.0 → 2.32.0`), so they can catch an unwanted bump before deploying — even under `Metadata: auto`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed.
|
|
|
|
With no path argument, `generate-metadata` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run `wmill generate-metadata --dry-run` — it lists each stale item with a reason (`content changed` or `depends on <path>`) without changing anything — then narrow with a path argument (`wmill generate-metadata f/foo`) or `--strict-folder-boundaries`.
|
|
|
|
If the on-disk `.lock` and `.script.yaml` are already correct and only `wmill-lock.yaml` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use `wmill generate-metadata rehash` — it re-records hashes from disk with no backend round-trip and no dependency changes.
|
|
|
|
### After writing — offer to test, don't wait passively
|
|
|
|
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run `wmill script preview` with sample args?"). Do not present a multi-option menu.
|
|
|
|
If the user already asked to test/run/try the script in their original request, skip the offer and just execute `wmill script preview <path> -d '<args>'` directly — pick plausible args from the script's declared parameters. The shape varies by language: `main(...)` for code languages, the SQL dialect's own placeholder syntax (`$1` for PostgreSQL, `?` for MySQL/Snowflake, `@P1` for MSSQL, `@name` for BigQuery, etc.), positional `$1`, `$2`, … for Bash, `param(...)` for PowerShell.
|
|
|
|
`wmill script preview` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). `wmill generate-metadata` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's `AGENTS.md` opts in), per "Keep metadata in sync" above. Only `wmill sync push` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push.
|
|
|
|
For a **visual** open-the-script-in-the-dev-page preview (rather than `script preview`'s run-and-print-result), use the `preview` skill.
|
|
|
|
Use `wmill resource-type list --schema` to discover available resource types."""
|
|
|
|
wac_cli_intro = f"""{script_cli_intro}
|
|
|
|
Workflow-as-Code files use the normal script CLI workflow. There are no separate WAC deploy commands."""
|
|
|
|
intro_content = {
|
|
'wac_cli': wac_cli_intro,
|
|
}
|
|
|
|
extra_sdk_content = {
|
|
'wac': "\n\n".join(filter(None, [wac_ts_md, wac_py_md])),
|
|
}
|
|
|
|
skills_generated = []
|
|
|
|
# Generate script skills for each language
|
|
for lang_key, lang_content in languages.items():
|
|
if lang_key not in LANGUAGE_METADATA:
|
|
print(f" Warning: No metadata for language '{lang_key}', skipping")
|
|
continue
|
|
|
|
metadata = LANGUAGE_METADATA[lang_key]
|
|
skill_name = f"write-script-{lang_key}"
|
|
skill_dir = OUTPUT_SKILLS_DIR / skill_name
|
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Determine which SDK to include
|
|
language_sdk_content = ''
|
|
if lang_key in TS_SDK_LANGUAGES:
|
|
language_sdk_content = ts_sdk_md
|
|
elif lang_key in PY_SDK_LANGUAGES:
|
|
language_sdk_content = py_sdk_md
|
|
|
|
skill_content = generate_skill_content(
|
|
skill_name=skill_name,
|
|
description=metadata['description'],
|
|
intro=script_cli_intro,
|
|
content=lang_content,
|
|
sdk_content=language_sdk_content
|
|
)
|
|
|
|
(skill_dir / "SKILL.md").write_text(skill_content)
|
|
skills_generated.append(skill_name)
|
|
|
|
# Generate other skills from definitions
|
|
# Note: Skills with schema_types (triggers, schedules) get base content only.
|
|
# Schemas are stored separately and combined at CLI init time.
|
|
for skill_def in SKILL_DEFINITIONS:
|
|
content = base_content.get(skill_def['content_key'], '')
|
|
if not content:
|
|
continue
|
|
|
|
skill_name = skill_def['name']
|
|
skill_dir = OUTPUT_SKILLS_DIR / skill_name
|
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Note: We no longer append schemas here. Skills with 'schema_types'
|
|
# will have schemas combined at CLI init time from SCHEMAS export.
|
|
|
|
skill_content = generate_skill_content(
|
|
skill_name=skill_name,
|
|
description=skill_def['description'],
|
|
intro=intro_content.get(skill_def.get('intro_key', ''), ''),
|
|
content=content,
|
|
sdk_content=extra_sdk_content.get(skill_def.get('sdk_content_key', ''), '')
|
|
)
|
|
|
|
(skill_dir / "SKILL.md").write_text(skill_content)
|
|
skills_generated.append(skill_name)
|
|
|
|
print(f" Generated {len(skills_generated)} skills")
|
|
return skills_generated
|
|
|
|
|
|
def generate_skills_ts_export(skills: list[str], schema_yaml_content: dict[str, str] | None = None) -> str:
|
|
"""Generate TypeScript file that exports skill metadata for the CLI.
|
|
|
|
Args:
|
|
skills: List of skill names
|
|
schema_yaml_content: Dict mapping schema keys (e.g., 'http_trigger') to YAML content
|
|
"""
|
|
schema_yaml_content = schema_yaml_content or {}
|
|
|
|
ts = "// Auto-generated by generate.py - DO NOT EDIT\n\n"
|
|
ts += "export interface SkillMetadata {\n"
|
|
ts += " name: string;\n"
|
|
ts += " description: string;\n"
|
|
ts += " languageKey?: string;\n"
|
|
ts += "}\n\n"
|
|
|
|
ts += "export const SKILLS: SkillMetadata[] = [\n"
|
|
|
|
skill_desc_map = {s['name']: s['description'] for s in SKILL_DEFINITIONS}
|
|
|
|
for skill in skills:
|
|
if skill.startswith('write-script-'):
|
|
lang_key = skill.replace('write-script-', '')
|
|
if lang_key in LANGUAGE_METADATA:
|
|
metadata = LANGUAGE_METADATA[lang_key]
|
|
ts += f' {{ name: "{skill}", description: "{metadata["description"]}", languageKey: "{lang_key}" }},\n'
|
|
elif skill in skill_desc_map:
|
|
ts += f' {{ name: "{skill}", description: "{skill_desc_map[skill]}" }},\n'
|
|
|
|
ts += "];\n\n"
|
|
|
|
# Generate the skills content inline for bundling
|
|
ts += "// Skill content for each skill (loaded inline for bundling)\n"
|
|
ts += "export const SKILL_CONTENT: Record<string, string> = {\n"
|
|
|
|
# We'll read the generated files and embed them
|
|
for skill in skills:
|
|
skill_path = OUTPUT_SKILLS_DIR / skill / "SKILL.md"
|
|
if skill_path.exists():
|
|
content = skill_path.read_text()
|
|
escaped = escape_for_ts(content)
|
|
ts += f' "{skill}": `{escaped}`,\n'
|
|
|
|
ts += "};\n\n"
|
|
|
|
# Generate SCHEMAS export (YAML content for each schema)
|
|
ts += "// YAML schema content for triggers and schedules\n"
|
|
ts += "export const SCHEMAS: Record<string, string> = {\n"
|
|
|
|
for schema_key, yaml_content in sorted(schema_yaml_content.items()):
|
|
escaped = escape_for_ts(yaml_content)
|
|
ts += f' "{schema_key}": `{escaped}`,\n'
|
|
|
|
ts += "};\n\n"
|
|
|
|
# Generate SCHEMA_MAPPINGS export (maps skill names to their schemas)
|
|
ts += "// Maps skill names to their schema types and file patterns\n"
|
|
ts += "export interface SchemaMapping {\n"
|
|
ts += " name: string;\n"
|
|
ts += " schemaKey: string;\n"
|
|
ts += " filePattern: string;\n"
|
|
ts += "}\n\n"
|
|
ts += "export const SCHEMA_MAPPINGS: Record<string, SchemaMapping[]> = {\n"
|
|
|
|
for skill_name, schema_types in SCHEMA_MAPPINGS.items():
|
|
ts += f' "{skill_name}": [\n'
|
|
for schema_name, file_suffix in schema_types:
|
|
ts += f' {{ name: "{schema_name}", schemaKey: "{file_suffix}", filePattern: "*.{file_suffix}.yaml" }},\n'
|
|
ts += " ],\n"
|
|
|
|
ts += "};\n"
|
|
|
|
return ts
|
|
|
|
|
|
def format_schema_for_markdown(schema_yaml: str, schema_name: str, file_pattern: str) -> str:
|
|
"""Format a standalone schema block for plugin skill files."""
|
|
return f"""## {schema_name} (`{file_pattern}`)
|
|
|
|
Must be a YAML file that adheres to the following schema:
|
|
|
|
```yaml
|
|
{schema_yaml.strip()}
|
|
```"""
|
|
|
|
|
|
def render_plugin_skill_content(skill_name: str, schema_yaml_content: dict[str, str]) -> str:
|
|
"""Render plugin-ready skill content from generated base skill files."""
|
|
skill_path = OUTPUT_SKILLS_DIR / skill_name / "SKILL.md"
|
|
if not skill_path.exists():
|
|
raise FileNotFoundError(f"Missing generated skill content for {skill_name}: {skill_path}")
|
|
|
|
skill_content = skill_path.read_text()
|
|
schema_mappings = SCHEMA_MAPPINGS.get(skill_name, [])
|
|
if not schema_mappings:
|
|
return skill_content
|
|
|
|
schema_docs = []
|
|
for schema_name, schema_key in schema_mappings:
|
|
schema_yaml = schema_yaml_content.get(schema_key)
|
|
if not schema_yaml:
|
|
continue
|
|
schema_docs.append(
|
|
format_schema_for_markdown(
|
|
schema_yaml=schema_yaml,
|
|
schema_name=schema_name,
|
|
file_pattern=f"*.{schema_key}.yaml",
|
|
)
|
|
)
|
|
|
|
if not schema_docs:
|
|
return skill_content
|
|
|
|
return f"{skill_content}\n\n" + "\n\n".join(schema_docs)
|
|
|
|
|
|
def resolve_plugin_skills_dir(plugin_dir: Path) -> Path:
|
|
"""Resolve the plugin skills directory from a repo root, plugin root, or skills dir."""
|
|
plugin_dir = plugin_dir.expanduser().resolve()
|
|
|
|
plugin_root = plugin_dir / "plugins" / "windmill"
|
|
if (plugin_root / ".claude-plugin" / "plugin.json").exists():
|
|
return plugin_root / "skills"
|
|
|
|
plugin_skills_dir = plugin_dir / "skills"
|
|
plugin_json = plugin_dir / ".claude-plugin" / "plugin.json"
|
|
if plugin_json.exists():
|
|
return plugin_skills_dir
|
|
|
|
if plugin_dir.name == "skills":
|
|
return plugin_dir
|
|
|
|
return plugin_skills_dir
|
|
|
|
|
|
def generate_plugin_skills(
|
|
plugin_dir: Path,
|
|
skills: list[str],
|
|
schema_yaml_content: dict[str, str],
|
|
) -> Path:
|
|
"""Generate standalone skills in a Claude plugin checkout."""
|
|
skills_dir = resolve_plugin_skills_dir(plugin_dir)
|
|
skills_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
expected_skills = set(skills)
|
|
for existing in skills_dir.iterdir():
|
|
if existing.is_dir() and existing.name not in expected_skills:
|
|
shutil.rmtree(existing)
|
|
|
|
for skill_name in skills:
|
|
skill_dir = skills_dir / skill_name
|
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
(skill_dir / "SKILL.md").write_text(
|
|
render_plugin_skill_content(skill_name, schema_yaml_content)
|
|
)
|
|
|
|
print(f"\nGenerated for plugin:")
|
|
print(f" - {skills_dir} ({len(skills)} skills)")
|
|
return skills_dir
|
|
|
|
|
|
# =============================================================================
|
|
# Context7 Docs Repo Generation
|
|
# =============================================================================
|
|
|
|
# Files in the context7 target directory that must survive a regeneration
|
|
# (everything else is wiped to keep the export deterministic).
|
|
CONTEXT7_PRESERVE = frozenset(
|
|
{
|
|
".git",
|
|
".github",
|
|
".gitignore",
|
|
".gitattributes",
|
|
"CODEOWNERS",
|
|
"LICENSE",
|
|
"LICENSE.md",
|
|
"context7.json",
|
|
}
|
|
)
|
|
|
|
# Name written into manifest.json — also used to recognise the docs repo
|
|
# when re-generating into an existing checkout.
|
|
CONTEXT7_REPO_NAME = "windmill-cli-docs"
|
|
|
|
|
|
def extract_agents_md_template() -> str:
|
|
"""Extract the AGENTS.wmill.md template string from cli/src/guidance/core.ts.
|
|
|
|
Keeping a single source of truth in TypeScript avoids drift between what
|
|
`wmill init` writes locally and what we publish for context7 ingestion.
|
|
"""
|
|
core_ts_path = SCRIPT_DIR.parent / "cli" / "src" / "guidance" / "core.ts"
|
|
content = core_ts_path.read_text()
|
|
# Anchor on the function name so adding other template-literal-returning
|
|
# functions to core.ts can't silently re-target the regex. The function
|
|
# was renamed from `generateAgentsMdContent` → `generateAgentsCliMdContent`
|
|
# when the managed file split out of AGENTS.md into AGENTS.cli.md.
|
|
match = re.search(
|
|
r"function\s+generateAgentsCliMdContent\b[\s\S]*?return\s+`([\s\S]*?)`;",
|
|
content,
|
|
)
|
|
if not match:
|
|
raise RuntimeError(
|
|
f"Could not extract AGENTS.wmill.md template from {core_ts_path}"
|
|
)
|
|
return _unescape_ts_template_literal(match.group(1))
|
|
|
|
|
|
def _unescape_ts_template_literal(raw: str) -> str:
|
|
"""Decode TS template-literal escapes in one pass.
|
|
|
|
Multi-pass `.replace()` would mangle e.g. `\\\\` -> `\\` -> `` ` `` if the
|
|
template ever contained a literal backslash followed by a backtick. A
|
|
single-pass scan is order-independent.
|
|
"""
|
|
return re.sub(
|
|
r"\\(.)",
|
|
lambda m: {"`": "`", "$": "$", "\\": "\\"}.get(m.group(1), m.group(0)),
|
|
raw,
|
|
)
|
|
|
|
|
|
def render_agents_md_for_docs(
|
|
skills: list[str], skill_desc_map: dict[str, str]
|
|
) -> str:
|
|
"""Render AGENTS.wmill.md exactly as `wmill init` would, for the docs repo.
|
|
|
|
The skill reference paths point at `.agents/skills/` (the canonical tree
|
|
that Codex/Pi read directly and that Claude Code mirrors under
|
|
`.claude/skills/`) — matching `buildSkillsReference` in
|
|
`cli/src/guidance/writer.ts`.
|
|
"""
|
|
template = extract_agents_md_template()
|
|
skills_reference = "\n".join(
|
|
f"- `.agents/skills/{name}/SKILL.md` - {skill_desc_map[name]}"
|
|
for name in skills
|
|
if name in skill_desc_map
|
|
)
|
|
return template.replace("${skillsReference}", skills_reference)
|
|
|
|
|
|
def build_skill_desc_map(skills: list[str]) -> dict[str, str]:
|
|
"""Map each skill name to its user-facing description.
|
|
|
|
Mirrors the logic in `generate_skills_ts_export`: language skills draw from
|
|
LANGUAGE_METADATA, everything else from SKILL_DEFINITIONS.
|
|
"""
|
|
desc_map = {s["name"]: s["description"] for s in SKILL_DEFINITIONS}
|
|
for skill in skills:
|
|
if skill.startswith("write-script-"):
|
|
lang_key = skill.replace("write-script-", "")
|
|
metadata = LANGUAGE_METADATA.get(lang_key)
|
|
if metadata:
|
|
desc_map[skill] = metadata["description"]
|
|
return desc_map
|
|
|
|
|
|
def _looks_like_windmill_manifest(path: Path) -> bool:
|
|
"""Return True iff `path` is a JSON file whose top-level `name` is ours.
|
|
|
|
Used to distinguish a previously-generated docs repo from an unrelated
|
|
project that happens to have a `manifest.json` (Chrome extensions, npm
|
|
packages, web app manifests, etc.).
|
|
"""
|
|
try:
|
|
data = json.loads(path.read_text())
|
|
except (OSError, json.JSONDecodeError):
|
|
return False
|
|
return isinstance(data, dict) and data.get("name") == CONTEXT7_REPO_NAME
|
|
|
|
|
|
def _verify_context7_target(target_dir: Path) -> None:
|
|
"""Refuse to wipe a non-empty dir that doesn't look like the docs repo.
|
|
|
|
A typo such as `--context7-dir .`, `~`, or the wrong checkout could
|
|
otherwise nuke unrelated files. We accept the target if it's empty/new,
|
|
if it has our ownership file, if its `manifest.json` self-identifies as
|
|
the windmill-cli-docs repo, or if its git origin points at one.
|
|
"""
|
|
if not target_dir.exists() or not any(target_dir.iterdir()):
|
|
return
|
|
|
|
if (target_dir / "context7.json").exists():
|
|
return
|
|
|
|
manifest_path = target_dir / "manifest.json"
|
|
if manifest_path.exists() and _looks_like_windmill_manifest(manifest_path):
|
|
return
|
|
|
|
git_dir = target_dir / ".git"
|
|
if git_dir.exists():
|
|
import subprocess
|
|
|
|
try:
|
|
origin = subprocess.run(
|
|
["git", "-C", str(target_dir), "config", "--get", "remote.origin.url"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
).stdout.strip()
|
|
if CONTEXT7_REPO_NAME in origin:
|
|
return
|
|
except subprocess.CalledProcessError:
|
|
pass
|
|
|
|
raise RuntimeError(
|
|
f"Refusing to overwrite {target_dir}: target does not look like the "
|
|
f"{CONTEXT7_REPO_NAME} docs repo.\n"
|
|
f"Expected one of:\n"
|
|
f" - a `context7.json` at the top level,\n"
|
|
f" - a `manifest.json` whose top-level `name` is {CONTEXT7_REPO_NAME!r},\n"
|
|
f" - a git remote `origin` containing '{CONTEXT7_REPO_NAME}'.\n"
|
|
f"If this is the right directory, add a `context7.json` and retry."
|
|
)
|
|
|
|
|
|
def clear_context7_dir(target_dir: Path) -> None:
|
|
"""Wipe the docs repo dir of previously generated content.
|
|
|
|
Preserves a small allowlist (.git, .github, LICENSE, context7.json, etc.)
|
|
so this can run against a real checkout without nuking version control or
|
|
CI config.
|
|
"""
|
|
if not target_dir.exists():
|
|
return
|
|
for entry in target_dir.iterdir():
|
|
if entry.name in CONTEXT7_PRESERVE:
|
|
continue
|
|
if entry.is_dir():
|
|
shutil.rmtree(entry)
|
|
else:
|
|
entry.unlink()
|
|
|
|
|
|
def _read_windmill_version() -> str | None:
|
|
"""Return the Windmill release version (e.g. '1.700.2'), or None if absent.
|
|
|
|
Sourced from `version.txt` at the repo root — the same file release-please
|
|
updates on every release.
|
|
"""
|
|
version_file = SCRIPT_DIR.parent / "version.txt"
|
|
if not version_file.exists():
|
|
return None
|
|
return version_file.read_text().strip() or None
|
|
|
|
|
|
def generate_context7_repo(
|
|
target_dir: Path,
|
|
skills: list[str],
|
|
schema_yaml_content: dict[str, str],
|
|
cli_commands_md: str,
|
|
) -> Path:
|
|
"""Generate a fully-rendered docs repo suitable for context7 ingestion.
|
|
|
|
Layout written to `target_dir`:
|
|
AGENTS.md # the prompt agents see in their projects
|
|
README.md # stable intro for humans / context7
|
|
manifest.json # version + skill list (for indexing)
|
|
cli-commands.md # full CLI flag reference
|
|
skills/<name>/SKILL.md # one rendered skill per file
|
|
"""
|
|
target_dir = target_dir.expanduser().resolve()
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
_verify_context7_target(target_dir)
|
|
clear_context7_dir(target_dir)
|
|
|
|
skill_desc_map = build_skill_desc_map(skills)
|
|
|
|
# AGENTS.md — the managed CLI guidance (what `wmill init` writes as
|
|
# AGENTS.wmill.md locally). Kept under the `AGENTS.md` filename here to
|
|
# preserve the existing context7 ingest path; docs consumers read this
|
|
# as the canonical AGENTS file.
|
|
(target_dir / "AGENTS.md").write_text(
|
|
render_agents_md_for_docs(skills, skill_desc_map)
|
|
)
|
|
|
|
# Full CLI reference at top level.
|
|
(target_dir / "cli-commands.md").write_text(cli_commands_md)
|
|
|
|
# One markdown per skill, with schemas inlined (no template placeholders).
|
|
skills_dir = target_dir / "skills"
|
|
skills_dir.mkdir(parents=True, exist_ok=True)
|
|
for skill_name in skills:
|
|
skill_dir = skills_dir / skill_name
|
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
(skill_dir / "SKILL.md").write_text(
|
|
render_plugin_skill_content(skill_name, schema_yaml_content)
|
|
)
|
|
|
|
# Stable README so the GitHub repo landing page tells readers (and
|
|
# context7's crawler) what they're looking at.
|
|
(target_dir / "README.md").write_text(_context7_readme(skills))
|
|
|
|
# Machine-readable index for context7 / downstream consumers.
|
|
# Note: the `name` field is also the marker `_verify_context7_target`
|
|
# uses to distinguish our `manifest.json` from generic ones.
|
|
manifest = {
|
|
"name": CONTEXT7_REPO_NAME,
|
|
"description": (
|
|
"Auto-generated Windmill CLI docs: agent prompt, skills, and "
|
|
"full CLI reference. Source: github.com/windmill-labs/windmill."
|
|
),
|
|
"skills": [
|
|
{"name": name, "description": skill_desc_map.get(name, "")}
|
|
for name in skills
|
|
],
|
|
}
|
|
version = _read_windmill_version()
|
|
if version:
|
|
manifest["version"] = version
|
|
(target_dir / "manifest.json").write_text(
|
|
json.dumps(manifest, indent=2) + "\n"
|
|
)
|
|
|
|
print(f"\nGenerated for context7 docs repo:")
|
|
print(f" - {target_dir} ({len(skills)} skills + AGENTS.md + cli-commands.md)")
|
|
return target_dir
|
|
|
|
|
|
def _context7_readme(skills: list[str]) -> str:
|
|
"""Render the README that ships at the root of the docs repo.
|
|
|
|
Doubles as a CLI quickstart for humans landing on the GitHub page and as
|
|
the top-level entry point context7 indexes first — keep it actionable.
|
|
"""
|
|
skill_lines = "\n".join(f"- `skills/{name}/SKILL.md`" for name in skills)
|
|
return f"""# Windmill CLI Quickstart
|
|
|
|
[`wmill`](https://www.windmill.dev/docs/advanced/cli) is the official command
|
|
line interface for [Windmill](https://www.windmill.dev) — an open-source
|
|
platform for internal tools, workflows, API integrations, background jobs, and
|
|
UIs. Use it to authenticate against a workspace, scaffold local projects,
|
|
sync scripts/flows/apps between your filesystem and a workspace, and run or
|
|
debug jobs from your terminal.
|
|
|
|
## Install
|
|
|
|
```sh
|
|
npm install -g windmill-cli
|
|
wmill --version
|
|
```
|
|
|
|
Upgrade later with `wmill upgrade`.
|
|
|
|
## Connect to a workspace
|
|
|
|
```sh
|
|
wmill workspace add
|
|
```
|
|
|
|
This walks you through adding a workspace profile — a `(name, remote URL,
|
|
workspace id, token)` tuple stored under `~/.config/windmill`. You can have
|
|
multiple profiles and switch between them with `wmill workspace switch <name>`.
|
|
|
|
A workspace token is created from the Windmill UI under
|
|
`User Settings → Tokens`. For self-hosted instances, point the remote at your
|
|
own URL (e.g. `https://windmill.example.com`).
|
|
|
|
## Initialize a project directory
|
|
|
|
```sh
|
|
wmill init
|
|
```
|
|
|
|
`wmill init` creates:
|
|
|
|
- `wmill.yaml` — sync configuration (which folders/types to track).
|
|
- `AGENTS.md` + `CLAUDE.md` — the agent prompt published in this repo.
|
|
- `.claude/skills/` and `.agents/skills/` — per-task guides used by AI coding
|
|
assistants (Claude Code, Codex, Pi). These are the same `SKILL.md` files
|
|
you'll find under `skills/` in this repo.
|
|
|
|
It also offers to bind a workspace profile to the current git branch and to
|
|
import git-sync settings from the backend if any are configured.
|
|
|
|
## Sync between local files and a workspace
|
|
|
|
```sh
|
|
wmill sync pull # workspace → local (writes flows, scripts, apps, etc.)
|
|
wmill sync push # local → workspace
|
|
```
|
|
|
|
Sync is idempotent and diff-aware: `wmill sync push --dry-run` previews the
|
|
changes without applying them. Use `--yaml` (recommended) to keep specs as
|
|
YAML rather than JSON.
|
|
|
|
For individual entities you can also use the type-specific commands:
|
|
|
|
```sh
|
|
wmill script push path/to/script.ts
|
|
wmill flow push path/to/flow.yaml
|
|
wmill app push path/to/app.yaml
|
|
wmill resource push path/to/resource.yaml
|
|
```
|
|
|
|
## Run, inspect, and debug jobs
|
|
|
|
```sh
|
|
wmill script run u/me/my_script --data '{{"foo": "bar"}}'
|
|
wmill flow run u/me/my_flow --data @inputs.json
|
|
wmill job list --failed --limit 20
|
|
wmill job get <job_id>
|
|
wmill job logs <job_id>
|
|
```
|
|
|
|
Logs and flow steps stream as the job runs. For flow failures, `wmill job get`
|
|
shows the step tree with each sub-job's id so you can drill in with
|
|
`wmill job logs <sub_job_id>`.
|
|
|
|
## Scaffold new entities
|
|
|
|
```sh
|
|
wmill script new u/me/path --language bun
|
|
wmill flow new u/me/path --summary "..."
|
|
wmill app new u/me/path --summary "..." --framework svelte
|
|
```
|
|
|
|
These create the correct folder layout and a minimal spec file, then print
|
|
next-step hints. Prefer them over hand-creating the folders — they pick the
|
|
right naming conventions for your workspace.
|
|
|
|
## Triggers and schedules
|
|
|
|
Triggers (HTTP routes, WebSocket, Kafka, NATS, MQTT, SQS, GCP Pub/Sub, Azure
|
|
Event Hubs, Email, Postgres CDC) and cron schedules are tracked as YAML files
|
|
synced alongside your scripts and flows. See `skills/triggers/SKILL.md` and
|
|
`skills/schedules/SKILL.md` for the full schemas.
|
|
|
|
## Completion
|
|
|
|
```sh
|
|
source <(wmill completions bash) # bash, zsh: source <(wmill completions zsh)
|
|
source (wmill completions fish | psub) # fish
|
|
```
|
|
|
|
## Reference
|
|
|
|
- `cli-commands.md` — every `wmill` command and flag, generated from the
|
|
source.
|
|
- `AGENTS.md` — the top-level prompt the CLI installs into each project (and
|
|
the same instructions AI coding assistants follow when working in a
|
|
Windmill repo).
|
|
- `skills/<name>/SKILL.md` — one self-contained guide per common task.
|
|
|
|
### Skills index
|
|
|
|
{skill_lines}
|
|
|
|
## About this repo
|
|
|
|
Auto-generated mirror of the Windmill CLI's bundled AI-agent guidance and
|
|
command reference, published for ingestion by docs aggregators such as
|
|
[context7](https://context7.com).
|
|
|
|
**Do not edit by hand.** This repo is regenerated from
|
|
[windmill-labs/windmill](https://github.com/windmill-labs/windmill) on every
|
|
release. Open issues and PRs in the source repo, not here. The generator is
|
|
`system_prompts/generate.py --context7-dir`.
|
|
"""
|
|
|
|
|
|
# =============================================================================
|
|
# Main Entry Point
|
|
# =============================================================================
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
"""Parse command line arguments."""
|
|
parser = argparse.ArgumentParser(
|
|
description=(
|
|
"Generate Windmill system prompts, CLI guidance, and optionally "
|
|
"plugin-ready standalone skills."
|
|
)
|
|
)
|
|
parser.add_argument(
|
|
"--plugin-dir",
|
|
type=Path,
|
|
help=(
|
|
"Optional plugin target. Accepts a windmill-claude-plugin repo root, "
|
|
"a plugin root, or a skills directory, and refreshes standalone skills there."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--context7-dir",
|
|
type=Path,
|
|
help=(
|
|
"Optional path to a docs-repo checkout (e.g. windmill-cli-docs). "
|
|
"Writes AGENTS.md, cli-commands.md, skills/, README.md, and manifest.json "
|
|
"with all placeholders resolved, suitable for context7 ingestion."
|
|
),
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main():
|
|
"""Main generation function."""
|
|
args = parse_args()
|
|
|
|
print("Generating system prompts documentation...")
|
|
|
|
# Ensure output directories exist
|
|
OUTPUT_SDKS_DIR.mkdir(parents=True, exist_ok=True)
|
|
OUTPUT_GENERATED_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Read SDK files
|
|
ts_content = ''
|
|
if TS_SDK_DIR.exists():
|
|
for ts_file in sorted(TS_SDK_DIR.glob('*.ts')):
|
|
if not ts_file.name.endswith('.d.ts'):
|
|
ts_content += ts_file.read_text() + '\n'
|
|
py_content = PY_SDK_PATH.read_text() if PY_SDK_PATH.exists() else ''
|
|
openflow_raw = OPENFLOW_SCHEMA_PATH.read_text() if OPENFLOW_SCHEMA_PATH.exists() else ''
|
|
|
|
# Extract only components.schemas from OpenFlow and convert to minified JSON
|
|
openflow_yaml = yaml.safe_load(openflow_raw) if openflow_raw else {}
|
|
openflow_schemas = openflow_yaml.get('components', {}).get('schemas', {})
|
|
openflow_schemas_json = json.dumps(openflow_schemas, separators=(',', ':'))
|
|
openflow_content = f"## OpenFlow Schema\n\n{openflow_schemas_json}"
|
|
|
|
# Extract TypeScript SDK info
|
|
print("Parsing TypeScript SDK...")
|
|
ts_functions = extract_ts_functions(ts_content)
|
|
ts_types = extract_ts_types(ts_content)
|
|
ts_sdk_md = generate_ts_sdk_markdown(ts_functions, ts_types)
|
|
(OUTPUT_SDKS_DIR / "typescript.md").write_text(ts_sdk_md)
|
|
print(f" Found {len(ts_functions)} functions, {len(ts_types)} types")
|
|
|
|
# Extract Python SDK info
|
|
print("Parsing Python SDK...")
|
|
py_functions = extract_py_functions(py_content)
|
|
py_classes = extract_py_classes(py_content)
|
|
py_sdk_md = generate_py_sdk_markdown(py_functions, py_classes)
|
|
(OUTPUT_SDKS_DIR / "python.md").write_text(py_sdk_md)
|
|
print(f" Found {len(py_functions)} functions, {len(py_classes)} classes")
|
|
|
|
# Extract datatable-specific SDK docs (for app mode system prompt)
|
|
print("Extracting datatable SDK docs...")
|
|
datatable_ts_md = extract_datatable_ts_sdk()
|
|
datatable_py_md = extract_datatable_py_sdk(py_content)
|
|
(OUTPUT_SDKS_DIR / "datatable-typescript.md").write_text(datatable_ts_md)
|
|
(OUTPUT_SDKS_DIR / "datatable-python.md").write_text(datatable_py_md)
|
|
|
|
# Extract Workflow-as-Code SDK docs (for WAC skills and prompt helpers)
|
|
print("Extracting Workflow-as-Code SDK docs...")
|
|
wac_ts_md = extract_wac_ts_sdk(ts_content)
|
|
wac_py_md = extract_wac_py_sdk(py_content)
|
|
(OUTPUT_SDKS_DIR / "wac-typescript.md").write_text(wac_ts_md)
|
|
(OUTPUT_SDKS_DIR / "wac-python.md").write_text(wac_py_md)
|
|
|
|
# Read base prompts
|
|
print("Assembling complete prompts...")
|
|
base_dir = SCRIPT_DIR / "base"
|
|
languages_dir = SCRIPT_DIR / "languages"
|
|
|
|
script_base = read_markdown_file(base_dir / "script-base.md")
|
|
flow_base = read_markdown_file(base_dir / "flow-base.md")
|
|
resources_base = read_markdown_file(base_dir / "resources.md")
|
|
raw_app_base = read_markdown_file(base_dir / "raw-app.md")
|
|
pipeline_base = read_markdown_file(base_dir / "pipeline-base.md")
|
|
workflow_as_code_base = read_markdown_file(base_dir / "workflow-as-code.md")
|
|
flow_cli = read_markdown_file(base_dir / "flow-cli.md")
|
|
flow_chat_special_modules = read_markdown_file(base_dir / "flow-chat-special-modules.md")
|
|
|
|
# Read language files
|
|
languages = {}
|
|
for lang_file in sorted(languages_dir.glob("*.md")):
|
|
languages[lang_file.stem] = lang_file.read_text()
|
|
|
|
# Extract and generate CLI commands documentation
|
|
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")
|
|
|
|
# Extract schemas from backend OpenAPI for CLI format documentation
|
|
print("Extracting backend OpenAPI schemas...")
|
|
cli_schemas = {}
|
|
backend_schemas = {}
|
|
if BACKEND_OPENAPI_PATH.exists():
|
|
backend_openapi_raw = BACKEND_OPENAPI_PATH.read_text()
|
|
backend_openapi = yaml.safe_load(backend_openapi_raw)
|
|
backend_schemas = backend_openapi.get('components', {}).get('schemas', {})
|
|
|
|
# Extract and transform schemas for CLI format (removing server-managed fields)
|
|
schema_names = [
|
|
'Schedule', 'NewSchedule',
|
|
'HttpTrigger', 'NewHttpTrigger',
|
|
'WebsocketTrigger', 'NewWebsocketTrigger',
|
|
'KafkaTrigger', 'NewKafkaTrigger',
|
|
'NatsTrigger', 'NewNatsTrigger',
|
|
'PostgresTrigger', 'NewPostgresTrigger',
|
|
'MqttTrigger', 'NewMqttTrigger',
|
|
'SqsTrigger', 'NewSqsTrigger',
|
|
'GcpTrigger',
|
|
'AzureTrigger',
|
|
'EmailTrigger', 'NewEmailTrigger',
|
|
]
|
|
for schema_name in schema_names:
|
|
if schema_name in backend_schemas:
|
|
cli_schemas[schema_name] = extract_cli_schema(backend_schemas[schema_name], backend_schemas, openflow_schemas)
|
|
|
|
print(f" Extracted {len(cli_schemas)} schemas for CLI format")
|
|
else:
|
|
print(f" Warning: Backend OpenAPI file not found at {BACKEND_OPENAPI_PATH}")
|
|
|
|
# Generate standalone schema files for triggers and schedules
|
|
schema_yaml_content = generate_schema_files(cli_schemas)
|
|
generate_workspace_tool_zod_schemas(backend_schemas, openflow_schemas)
|
|
|
|
# Assemble prompts for export
|
|
prompts = {
|
|
# Base prompts
|
|
'SCRIPT_BASE': script_base,
|
|
'FLOW_BASE': flow_base,
|
|
'RESOURCES_BASE': resources_base,
|
|
'RAW_APP_BASE': raw_app_base,
|
|
'PIPELINE_BASE': pipeline_base,
|
|
'WORKFLOW_AS_CODE_BASE': workflow_as_code_base,
|
|
'FLOW_CHAT_SPECIAL_MODULES': flow_chat_special_modules,
|
|
|
|
# SDKs
|
|
'SDK_TYPESCRIPT': ts_sdk_md,
|
|
'SDK_PYTHON': py_sdk_md,
|
|
'WAC_SDK_TYPESCRIPT': wac_ts_md,
|
|
'WAC_SDK_PYTHON': wac_py_md,
|
|
|
|
# Datatable-specific SDK docs (for app mode)
|
|
'DATATABLE_SDK_TYPESCRIPT': datatable_ts_md,
|
|
'DATATABLE_SDK_PYTHON': datatable_py_md,
|
|
|
|
# Schema (raw YAML content)
|
|
'OPENFLOW_SCHEMA': openflow_content,
|
|
|
|
# CLI
|
|
'CLI_COMMANDS': cli_commands,
|
|
}
|
|
|
|
# Add language prompts
|
|
for lang_name, lang_content in languages.items():
|
|
prompts[f'LANG_{lang_name.upper()}'] = lang_content
|
|
|
|
# Generate TypeScript exports
|
|
ts_exports = generate_ts_exports(prompts)
|
|
(OUTPUT_GENERATED_DIR / "prompts.ts").write_text(ts_exports)
|
|
(OUTPUT_GENERATED_DIR / "prompts.d.ts").write_text(generate_ts_declarations(prompts))
|
|
|
|
# Generate complete script.md (all languages combined)
|
|
script_md_parts = [script_base]
|
|
for lang_name in sorted(languages.keys()):
|
|
script_md_parts.append(languages[lang_name])
|
|
script_md_parts.extend([ts_sdk_md, py_sdk_md])
|
|
script_md = "\n\n".join(filter(None, script_md_parts))
|
|
(OUTPUT_GENERATED_DIR / "script.md").write_text(script_md)
|
|
|
|
# Generate complete flow.md
|
|
flow_md_parts = [flow_base, openflow_content]
|
|
flow_md = "\n\n".join(filter(None, flow_md_parts))
|
|
(OUTPUT_GENERATED_DIR / "flow.md").write_text(flow_md)
|
|
|
|
# Generate an index file
|
|
index_content = """// Auto-generated by generate.py - DO NOT EDIT
|
|
// Re-export all prompts
|
|
export * from './prompts';
|
|
|
|
import * as prompts from './prompts';
|
|
|
|
// Languages that use the TypeScript SDK
|
|
const TS_SDK_LANGUAGES = ['bun', 'deno', 'nativets', 'bunnative'];
|
|
|
|
// Languages that use the Python SDK
|
|
const PY_SDK_LANGUAGES = ['python3'];
|
|
|
|
// Languages that use the TypeScript Workflow-as-Code SDK
|
|
const WAC_TS_SDK_LANGUAGES = ['bun'];
|
|
|
|
// Languages that use the Python Workflow-as-Code SDK
|
|
const WAC_PY_SDK_LANGUAGES = PY_SDK_LANGUAGES;
|
|
|
|
// Helper to combine prompts for scripts
|
|
export function getScriptPrompt(language: string): string {
|
|
const langKey = `LANG_${language.toUpperCase()}` as keyof typeof prompts;
|
|
const langPrompt = (prompts as Record<string, string>)[langKey] || '';
|
|
|
|
// Determine which SDK to include based on language
|
|
let sdkPrompt = '';
|
|
if (TS_SDK_LANGUAGES.includes(language)) {
|
|
sdkPrompt = prompts.SDK_TYPESCRIPT;
|
|
} else if (PY_SDK_LANGUAGES.includes(language)) {
|
|
sdkPrompt = prompts.SDK_PYTHON;
|
|
}
|
|
|
|
return [
|
|
prompts.SCRIPT_BASE,
|
|
langPrompt,
|
|
sdkPrompt
|
|
].filter(Boolean).join('\\n\\n');
|
|
}
|
|
|
|
// Helper to combine prompts for flows
|
|
export function getFlowPrompt(): string {
|
|
return [
|
|
prompts.FLOW_BASE,
|
|
prompts.OPENFLOW_SCHEMA
|
|
].filter(Boolean).join('\\n\\n');
|
|
}
|
|
|
|
// Helper for resource & variable authoring
|
|
export function getResourcePrompt(): string {
|
|
return prompts.RESOURCES_BASE;
|
|
}
|
|
|
|
// Helper for raw app authoring (chat consumers)
|
|
export function getRawAppPrompt(): string {
|
|
return prompts.RAW_APP_BASE;
|
|
}
|
|
|
|
// Helper for data pipeline authoring (chat consumers)
|
|
export function getPipelinePrompt(): string {
|
|
return prompts.PIPELINE_BASE;
|
|
}
|
|
|
|
// Helper to get the datatable SQL SDK reference (wmill.datatable()).
|
|
// Pass a language to get only that SDK; omit it to get both.
|
|
export function getDatatableSdkReference(language?: string): string {
|
|
if (language == null) {
|
|
return [
|
|
prompts.DATATABLE_SDK_TYPESCRIPT,
|
|
prompts.DATATABLE_SDK_PYTHON
|
|
].filter(Boolean).join('\\n\\n');
|
|
}
|
|
if (TS_SDK_LANGUAGES.includes(language)) {
|
|
return prompts.DATATABLE_SDK_TYPESCRIPT;
|
|
}
|
|
if (PY_SDK_LANGUAGES.includes(language)) {
|
|
return prompts.DATATABLE_SDK_PYTHON;
|
|
}
|
|
// Unknown language: return both rather than nothing.
|
|
return [
|
|
prompts.DATATABLE_SDK_TYPESCRIPT,
|
|
prompts.DATATABLE_SDK_PYTHON
|
|
].filter(Boolean).join('\\n\\n');
|
|
}
|
|
|
|
// Helper to combine prompts for Workflow-as-Code scripts
|
|
export function getWorkflowAsCodePrompt(language?: string): string {
|
|
let sdkPrompt = '';
|
|
|
|
if (language == null) {
|
|
sdkPrompt = [
|
|
prompts.WAC_SDK_TYPESCRIPT,
|
|
prompts.WAC_SDK_PYTHON
|
|
].filter(Boolean).join('\\n\\n');
|
|
} else if (WAC_TS_SDK_LANGUAGES.includes(language)) {
|
|
sdkPrompt = prompts.WAC_SDK_TYPESCRIPT;
|
|
} else if (WAC_PY_SDK_LANGUAGES.includes(language)) {
|
|
sdkPrompt = prompts.WAC_SDK_PYTHON;
|
|
} else {
|
|
return '';
|
|
}
|
|
|
|
return [
|
|
prompts.WORKFLOW_AS_CODE_BASE,
|
|
sdkPrompt
|
|
].filter(Boolean).join('\\n\\n');
|
|
}
|
|
"""
|
|
(OUTPUT_GENERATED_DIR / "index.ts").write_text(index_content)
|
|
|
|
index_dts_content = """export * from './prompts';
|
|
export declare function getScriptPrompt(language: string): string;
|
|
export declare function getFlowPrompt(): string;
|
|
export declare function getResourcePrompt(): string;
|
|
export declare function getRawAppPrompt(): string;
|
|
export declare function getPipelinePrompt(): string;
|
|
export declare function getDatatableSdkReference(language?: string): string;
|
|
export declare function getWorkflowAsCodePrompt(language?: string): string;
|
|
"""
|
|
(OUTPUT_GENERATED_DIR / "index.d.ts").write_text(index_dts_content)
|
|
|
|
# Generate skill files for Claude Code
|
|
CLI_GUIDANCE_DIR.mkdir(parents=True, exist_ok=True)
|
|
skills = generate_skills(
|
|
languages=languages,
|
|
ts_sdk_md=ts_sdk_md,
|
|
py_sdk_md=py_sdk_md,
|
|
wac_ts_md=wac_ts_md,
|
|
wac_py_md=wac_py_md,
|
|
flow_cli=flow_cli,
|
|
flow_base=flow_base,
|
|
cli_commands=cli_commands,
|
|
openflow_content=openflow_content,
|
|
cli_schemas=cli_schemas
|
|
)
|
|
|
|
# Generate skills TypeScript export for CLI
|
|
skills_ts = generate_skills_ts_export(skills, schema_yaml_content)
|
|
|
|
# Replace hardcoded path conventions with placeholders for CLI runtime resolution.
|
|
# init.ts resolves these based on the nonDottedPaths setting in wmill.yaml.
|
|
# (Frontend auto-generated files keep the default non-dotted conventions.)
|
|
skills_ts = (skills_ts
|
|
.replace("\\`__flow\\`", "\\`{{FLOW_SUFFIX}}\\`")
|
|
.replace(
|
|
"Inline script files should NOT include \\`.inline_script.\\`"
|
|
" in their names (e.g. use \\`a.ts\\`, not \\`a.inline_script.ts\\`).",
|
|
"{{INLINE_SCRIPT_NAMING}}"
|
|
)
|
|
.replace("my_flow__flow", "my_flow{{FLOW_SUFFIX}}")
|
|
.replace("my_app__raw_app/", "my_app{{RAW_APP_SUFFIX}}/")
|
|
)
|
|
(CLI_GUIDANCE_DIR / "skills.gen.ts").write_text(skills_ts)
|
|
|
|
print(f"\nGenerated files:")
|
|
print(f" - auto-generated/sdks/typescript.md")
|
|
print(f" - auto-generated/sdks/python.md")
|
|
print(f" - auto-generated/sdks/wac-typescript.md")
|
|
print(f" - auto-generated/sdks/wac-python.md")
|
|
print(f" - auto-generated/cli/cli-commands.md (auto-generated from CLI source)")
|
|
print(f" - auto-generated/prompts.ts")
|
|
print(f" - auto-generated/prompts.d.ts")
|
|
print(f" - auto-generated/index.ts")
|
|
print(f" - auto-generated/script.md")
|
|
print(f" - auto-generated/flow.md")
|
|
print(f" - auto-generated/skills/ ({len(skills)} skills)")
|
|
print(f" - auto-generated/schemas/ ({len(schema_yaml_content)} schema files)")
|
|
print(f"\nGenerated for CLI:")
|
|
print(f" - cli/src/guidance/skills.gen.ts")
|
|
|
|
if args.plugin_dir:
|
|
generate_plugin_skills(args.plugin_dir, skills, schema_yaml_content)
|
|
|
|
if args.context7_dir:
|
|
generate_context7_repo(
|
|
args.context7_dir, skills, schema_yaml_content, cli_commands
|
|
)
|
|
|
|
print("\nDone!")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|