Files
windmill/system_prompts/generate.py
Diego Imbert e47aedac0a feat: add SQL migrations for data tables (#9693)
* feat: add datatable_migrations table

* feat: add route to run datatable migrations

* feat: sync datatable migrations as .up.sql/.down.sql files

* feat: add datatable migrate up/down commands and post-push run prompt

* feat: add datatable migrate new command to scaffold migrations

* feat: add datatable migrations management UI

* feat: prompt to create migration on DDL in datatable SQL editors

* feat: support running a single specific datatable migration

* feat: view migration content, run single migration, fix stacked modal

* feat: per-row revert button with out-of-order warning

* fix: avoid migrations list flicker on refresh after an action

* feat: generate initial datatable migration via pg_dump

* fix: surface datatable migration API error details in toasts

* fix: revert created migration if create-and-run fails to run

* fix: include postgres error detail in migration run/rollback failures

* feat: sync datatable migrations as files via the workspace export

* refactor: move datatable migrations to migrations/datatable/ path

* fix: drop redundant datatable_migration label in sync output

* fix: exclude datatable migration sql files from script metadata generation

* feat: run datatable migrations as user-permissioned labeled jobs

* feat: reject invalid datatable migrations on sync push

* feat: datatable migrate up/down default to all datatables, --datatable to target one

* fix: surface postgres error detail when datatable migrations fail to run

* chore: regenerate CLI docs for datatable migrate commands

* feat: default new datatable migration to a BEGIN/END transaction template

* fix: validate datatable migration name and datatable at the API boundary

* fix: ensure detected DDL ends with semicolon when wrapped in transaction

* fix: re-prompt instead of stripping DDL when new-migration modal is cancelled

* feat: refresh datatable schema after running a migration from the SQL REPL

* feat: record db manager DDL on data tables as migrations

* feat: make datatable migrations opt-in per data table

* fix: make migration view editor read-only so its code can scroll

* fix: don't re-prompt DDL guard when creating a migration without running

* feat: generate down migrations for db manager DDL (postgres)

* fix: correct down migration for db manager alters (no double-wrap, serial)

* feat: explain migrations purpose with a tooltip in the migrations modal

* compare paeg

* feat: add datatable_migration kind to workspace diff pipeline

* chore: point ee-repo-ref at datatable_migration git-sync companion

* fix: harden datatable migration version allocation and initial-migration bookkeeping, add tests

* feat: deploy and run datatable migrations on workspace merge

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Refactor + handle datatable setting delete/rename

* refactor: move datatable migration rename/delete cascade into module

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(windmill-utils-internal): bump to 1.7.1 for datatable migration deploy provider methods

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(db-manager): add Migrations button to top bar, make Refresh icon-only

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* BEGIN/END placeholder in down migration

* feat: autofocus migration name input and flag it red when empty

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(datatable-migrations): allow non-admins to create/run/revert migrations, gate only opt in/out

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* border nits

* refresh db manager schema on migrations

* BEGIN/END scaffold in CLI

* feat(cli): push local datatable migrations before running on migrate up

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: flag invalid migration name with red border, not just empty

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: drop random slug from auto-generated migration names

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: offer revert-and-delete when deleting an installed migration

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: record fork merge as a migration when target datatable opts in

* nit

* clone migrations on fork

* windmill-utils-internal

* fix(datatable-migrations): serialize run/rollback with a per-db advisory lock

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(db-manager): fail closed when migrations-status check errors on DDL apply

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: fix generate_initial migration ordering comment to match code

* chore(datatable-migrations): remove unused update_datatable_migrations endpoint

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: run DDL migration guard on the script editor Test button

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* split

* ee-repo-ref

* chore(frontend): sync package-lock with package.json (@emnapi deps)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(datatable-migrations): never resolve instance credentials into migration job args

datatable_database_arg eagerly resolved instance data-table credentials
(including the shared instance-wide Postgres password) and passed them as the
migration job's plaintext `database` arg, landing in v2_job.args. Since the
run route has no admin gate, a non-admin could run a migration and read
args.database to recover the password, granting cross-workspace psql access to
all instance data-table DBs.

Pass a `datatable://<name>` reference for both resource-backed and instance
data tables instead; the pg executor already resolves it to real credentials
server-side at run time, so nothing sensitive is ever stored in the job args.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* nit

* fix: handle dollar-quoting and comments when splitting SQL statements

* feat: deploy datatable migrations on merge with explicit opt-in error

* fix(frontend): sync package-lock with npm 11 peer-dep resolution

npm ci failed with 'Missing: @emnapi/core@1.11.2 / @emnapi/runtime@1.11.2 from
lock file'. @napi-rs/wasm-runtime declares @emnapi/core|runtime ^1.7.1 as
peerDependencies while @rolldown/binding-wasm32-wasi pins them to exactly
1.10.0. Newer npm (bundled with node 24 in CI) installs the peer deps at the
highest match (1.11.2) alongside rolldown's nested 1.10.0, so the ideal tree
needs both versions; the committed lock only had 1.10.0.

Regenerate the lock with npm 11.18 so it carries both 1.11.2 (top-level, for
the peer deps) and 1.10.0 (nested, for rolldown's pin). Verified npm ci passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* nit npm publish

* fix: fail closed on migrations-status error in fork schema merge

* nit CI emnapi/core version

* prevent initial_datatable_migration if migrations already exist

* fix(datatable-migrations): validate persisted data table names as path segments

edit_datatable_config only validated rename segments, not the actual
settings.datatables keys, so a data table could be saved directly under a name
like '..' or one containing '/'. Since new tables default to
migrations_enabled = true, generate_initial_datatable_migration would then
insert a migration row and the sync export would build
migrations/datatable/<name>/... paths from that name, producing malformed or
directory-escaping export paths.

Validate every persisted data table name in edit_datatable_config (alongside
the existing rename checks) and add validate_datatable_path_segment to
generate_initial_datatable_migration for defense in depth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: scope datatable _wm_migrations by data table and cascade renames/deletes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(system_prompts): resolve nested local command groups in CLI docs generator

The CLI docs generator anchored on the first `new Command()` in a file and
never resolved locally-defined command groups passed as
`.command("name", localCmd)`. For datatable this flattened the nested
`migrate` group: it emitted `datatable new/up/down` plus a bare
`datatable migrate`, and mislabeled the datatable command with the migrate
group's description. jobs was broken the same way (its description was pull's,
and pull/push rendered empty).

Anchor block extraction on the `export default`ed command, recurse into
locally-defined `const x = new Command()` groups mounted as subcommands, and
render nested sub-subcommands. Regenerated docs now show
`datatable migrate new/up/down` and `jobs pull/push` with their real
options.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: drop unreleased _wm_migrations legacy-upgrade handling

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: return datatable migration SQL from getItemValue for the diff drawer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(frontend): use windmill-utils-internal 1.8.2 for migration diff drawer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* nit

* nit

* fix: handle datatable migration renames on push and dedupe timestamps

* fix: reject rewriting an already-applied datatable migration on upsert

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): add missing @emnapi/core and @emnapi/runtime lockfile entries

Resolves npm ci EUSAGE failure: the optional cpu:wasm32 @rolldown/binding-wasm32-wasi
declares deps on @emnapi/core@1.11.2 and @emnapi/runtime@1.11.2 that had no resolved
lockfile entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): datatable migrate up/down default to main datatable, not all

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: fail closed when applied status unreadable on datatable migration rewrite

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: surface full error detail in Database Manager DDL/query errors

* "See migration" button in the toast

* feat: add Enter shortcut to Create-a-migration in the DDL guard

* fix(frontend): warn before running a newly-created datatable migration out of order

The row-level Run action warns when earlier migrations are still pending, but
the create-and-run paths ran a just-created migration with `only` directly,
applying it ahead of older pending migrations without that confirmation.

Reuse the same "Run migration out of order" confirmation across all
create-and-run paths via a shared helper (datatableMigrationUtils):
- NewDataTableMigrationModal "Create and run" (and the DDL guard path)
- DatatableSchemaDiff fork→parent merge
- dbOps schema ops (DB manager create/alter/drop) — the pure factory throws a
  MigrationRunCancelled sentinel on decline, which DBTableEditor treats as a
  silent cancel

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: keep renamed datatable migrations visible in compare view

* fix: record per-migration deployment on datatable migrations disable

* fix(cli): run deployed datatable migrations after workspace merge

The merge command upserted datatable_migration definitions into the target
workspace and reported the item as successfully deployed, but never ran the
migrations. For forked datatables backed by separate databases, this left the
target schema unchanged until someone manually ran `wmill datatable migrate up`,
while the CLI reported a successful merge.

Collect the datatable migrations deployed (not deleted) into the target and,
after the deploy loop, offer to run them via the existing offerToRunNewMigrations
helper — the same post-deploy run prompt the push/sync path uses (interactive
only; `--yes`/non-TTY skip the mutating run, matching push behavior). Export
parseDatatableMigrationDeployPath so the merge path can parse the deployed items.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(backend): serialize datatable migration edits/deletes with the run lock

A migration run snapshots a migration's code_up from datatable_migrations and
only records its version in the data table's _wm_migrations after the job
succeeds. upsert_datatable_migration checked _wm_migrations before allowing an
edit but took no lock, so a concurrent edit could read "not applied yet",
rewrite code_up/code_down, and then the in-flight run would record the version
for the old SQL — leaving _wm_migrations pointing at SQL that was never applied
(migrate up then skips it; rollback runs a down that doesn't match).

Serialize definition rewrites and deletes with the same per-database advisory
lock the run/rollback paths use:
- Factor the connect+advisory-lock into lock_datatable_migration_runs and the
  applied-versions read into read_applied_versions_on_client.
- run_datatable_migrations now snapshots the definitions AFTER taking the lock,
  so code_up can't change between snapshot and version-record.
- upsert (when changing an existing def) and delete take the lock across the
  applied-check and the write; delete now rejects deleting an already-applied
  migration (would orphan its _wm_migrations record), symmetric with upsert.
  Both fail closed if the data table database is unreachable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): stack the out-of-order migration confirm above the DB editor preview

Creating a table on a migrations-enabled data table opened the DB table editor's
"Confirm running the following" preview modal, whose confirm triggers applyDdl,
which then asks for out-of-order confirmation. Both are ConfirmationModals with a
hardcoded z-[9999]; the out-of-order one lives in DBManagerContent (mounted before
the editor), so it rendered behind the still-open preview modal.

Add an optional zIndexClass prop to ConfirmationModal (default z-[9999],
backward-compatible) and give the DB-manager out-of-order confirm z-[10000] so it
stacks on top.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 27672e37df5d9dfde94f19963d5ffcdf8dd5448c

This commit updates the EE repository reference after PR #623 was merged in windmill-ee-private.

Previous ee-repo-ref: 6c287041cd7edd4a77a4bc07ad0e156cec32cce4

New ee-repo-ref: 27672e37df5d9dfde94f19963d5ffcdf8dd5448c

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-07-07 08:25:16 +00:00

2730 lines
99 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 extract_named_command_block(content: str, var_name: str) -> str | None:
"""Return the chained-call body of `const <var_name> = new Command() ...`,
from just after `new Command()` up to the next top-level statement.
Returns None when the var isn't a *direct* `new Command()` (e.g. it's wrapped
in a helper call like `auditListOptions(new Command()...)`), so callers can
fall back to a looser match.
"""
m = re.search(
r'const\s+' + re.escape(var_name) + r'\s*=\s*new\s+Command\(\)'
r'([\s\S]*?)(?=\n(?:const|let|var|async|function|export)\b)',
content,
)
return m.group(1) if m else None
def extract_exported_command_block(content: str) -> str | None:
"""Return the chained-call body of the command that is `export default`ed.
A command file may define helper `new Command()` groups (assigned to local
consts and mounted as nested subcommands via `.command("x", localCmd)`)
*before* the exported command. Anchoring on the first `new Command()` in the
file would merge those helpers into the top-level command, so resolve the
exported variable first and only then fall back to the first `new Command()`
(which covers inline/wrapped exports).
"""
export_match = re.search(r'export\s+default\s+(\w+)\s*;', content)
if export_match:
block = extract_named_command_block(content, export_match.group(1))
if block is not None:
return block
command_match = re.search(
r'(?:const\s+command\s*=\s*)?new\s+Command\(\)([\s\S]*?)(?=export\s+default)',
content,
)
return command_match.group(1) if command_match else None
def parse_command_block(
content: str, file_path: Path | None = None, block: str | 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. `block` may be passed to parse a specific pre-extracted
command body (used to recurse into locally-defined nested command groups).
"""
result = {
'description': '',
'options': [],
'subcommands': [],
'arguments': '',
'alias': ''
}
# Find the command block
if block is None:
block = extract_exported_command_block(content)
if block is None:
return result
# 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', []),
'subcommands': imported_cmd.get('subcommands', []),
})
continue
except Exception as e:
print(f" Warning: Could not parse imported command {second_arg}: {e}")
cmd_desc = ''
elif second_arg and re.search(
r'const\s+' + re.escape(second_arg) + r'\s*=\s*new\s+Command\(\)', content
):
# Locally-defined command group mounted as a subcommand
# (e.g. `.command("migrate", migrateCommand)`): recurse into its
# definition so its own subcommands/options are captured.
nested_block = extract_named_command_block(content, second_arg)
if nested_block is not None:
nested = parse_command_block(content, file_path, block=nested_block)
result['subcommands'].append({
'name': cmd_name,
'description': nested.get('description', ''),
'arguments': nested.get('arguments', ''),
'options': nested.get('options', []),
'subcommands': nested.get('subcommands', []),
})
continue
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"
# Nested sub-subcommands (e.g. `datatable migrate new`)
for subsub in sub.get('subcommands', []):
ss_args = f" {subsub['arguments']}" if subsub.get('arguments') else ""
md += f" - `{cmd['name']} {sub_name} {subsub['name']}{ss_args}`"
if subsub.get('description'):
md += f" - {subsub['description']}"
md += "\n"
for opt in subsub.get('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()